feat: switch from css zoom (rerunning layout at every scale) to pure view transform (more stable)

This commit is contained in:
Steffen Becker committed 2026-06-09 10:18:39 +02:00
1 parent 8d91801491
commit c641dda4e0
3 files changed
+109 -73

No files matched your search

+60 -29
View File
@@ -49,6 +49,23 @@
let element: HTMLDivElement;
let editorContainer: HTMLDivElement;
// Zoom is a CSS `transform: scale()` on .paper (so layout and pagination stay at
// 100%). A transform reserves no layout space, so .paper-scaler reserves the scaled
// footprint to drive the scrollbars and horizontal centering.
let paperEl: HTMLDivElement;
let docHeightDoc = $state(0); // document height, from pm-pagecount
let scaledWidth = $state(0);
let scaledHeight = $state(0);
function recomputeScaledSize() {
if (!paperEl) return;
const z = appliedZoom / 100;
const w = paperEl.offsetWidth; // unscaled page width (= --user-page-width)
const h = docHeightDoc || paperEl.offsetHeight; // unscaled document height
scaledWidth = Math.round(w * z);
scaledHeight = Math.round(h * z);
}
// Page cycle (page height + 20px gap) in document px. Orientation-dependent, so
// read live from --user-page-height (set by applyOrientationVars). Must match
// pageBreaks.ts. Fallback = A4 portrait (1123 + 20).
@@ -105,9 +122,9 @@
// --- Table page-break overlay ---
// pageBreaks.ts reports (via pm-pagecount) the in-cell table breaks in document px
// relative to .tiptap's top. We render the overlay in a .band-layer INSIDE the
// zoomed .paper (in document px) so CSS `zoom` scales it by the exact same transform
// as the .tiptap page background. Two pieces per break:
// relative to .tiptap's top. We render the overlay in a .band-layer inside .paper
// (in document px) so the same `transform: scale()` scales it identically to the
// .tiptap page background. Two pieces per break:
// • band — a solid page-coloured mask (content width) hiding the table's borders
// bleeding through the page margins, plus the close/open lines.
// • stripe — the dark page gap, drawn as ONE full-page-width element on top. Using
@@ -142,7 +159,7 @@
else spacersByKey.set(key, [sp]);
}
// Everything below is in document px (relative to .tiptap's top). The .band-layer
// lives inside the zoomed .paper, so `zoom` applies the scaling — no manual `* z`.
// lives inside the scaled .paper, so the transform applies the scaling — no `* z`.
const border = 1; // 1px page-edge line, like the CSS .tiptap background
bandStyles = tableBandsDoc.map((b) => {
// Anchor the band's vertical extent to the real rendered extent of this break's
@@ -233,6 +250,8 @@
$effect(() => {
appliedZoom; // track to fire after the pre-effect / DOM update
// Re-reserve the scaled scroll footprint for the new zoom (layout itself is frozen).
recomputeScaledSize();
// Zoom changes the table's rendered position — re-place the floating toolbar.
scheduleTableUi();
if (pendingAnchorDocY === null || !editorContainer || !element) return;
@@ -274,9 +293,12 @@
}
function onPageCount(e: Event) {
const detail = (e as CustomEvent<{ numPages: number; tableBreakBands?: TableBreakBand[] }>).detail;
const detail = (e as CustomEvent<{ numPages: number; docHeight?: number; tableBreakBands?: TableBreakBand[] }>).detail;
numPages = detail.numPages;
if (typeof detail.docHeight === 'number') docHeightDoc = detail.docHeight;
tableBandsDoc = detail.tableBreakBands ?? [];
// The document height changed → resize the scaled scroll footprint.
recomputeScaledSize();
updateCurrentPage();
// Pagination spacers can shift a table's position — re-place the toolbar/bands.
scheduleTableUi();
@@ -343,6 +365,11 @@
element.addEventListener('pm-pagecount', onPageCount);
editorContainer.addEventListener('scroll', onEditorScroll);
// Seed the scaled footprint before the first paint (so the page is centered, not
// briefly left-aligned), then refine it once layout settles. The pageBreaks plugin
// also fires pm-pagecount shortly after with the precise document height.
recomputeScaledSize();
requestAnimationFrame(recomputeScaledSize);
});
function onEditorScroll() {
@@ -361,26 +388,30 @@
</script>
<div class="editor" bind:this={editorContainer}>
<div class="paper" class:show-formatting-marks={showFormattingMarks} style="zoom: {appliedZoom / 100}">
<!-- Dedicated mount point that TipTap fully owns — keeping it free of Svelte
content avoids Svelte and ProseMirror fighting over the same parent's DOM. -->
<div bind:this={element} class="tiptap-host"></div>
{#if bandStyles.length}
<div class="band-layer">
{#each bandStyles as b}
<div
class="table-break-band"
style="top: {b.top}px; left: {b.left}px; width: {b.width}px; height: {b.height}px;"
></div>
{/each}
{#each gapStripeStyles as s}
<div
class="page-gap-stripe"
style="top: {s.top}px; width: {s.width}px; height: {s.height}px; background: {s.background};"
></div>
{/each}
</div>
{/if}
<!-- Reserves the scaled scroll footprint; the transform on .paper reserves none.
Before the first measure (size 0) it's left unsized so .paper isn't clipped. -->
<div class="paper-scaler" style={scaledWidth ? `width: ${scaledWidth}px; height: ${scaledHeight}px;` : ''}>
<div bind:this={paperEl} class="paper" class:show-formatting-marks={showFormattingMarks} style="transform: scale({appliedZoom / 100});">
<!-- Dedicated mount point that TipTap fully owns — keeping it free of Svelte
content avoids Svelte and ProseMirror fighting over the same parent's DOM. -->
<div bind:this={element} class="tiptap-host"></div>
{#if bandStyles.length}
<div class="band-layer">
{#each bandStyles as b}
<div
class="table-break-band"
style="top: {b.top}px; left: {b.left}px; width: {b.width}px; height: {b.height}px;"
></div>
{/each}
{#each gapStripeStyles as s}
<div
class="page-gap-stripe"
style="top: {s.top}px; width: {s.width}px; height: {s.height}px; background: {s.background};"
></div>
{/each}
</div>
{/if}
</div>
</div>
{#if tableUi.visible}
<TableToolbar {editor} top={tableUi.top} left={tableUi.left} />
@@ -388,10 +419,10 @@
</div>
<style>
/* Overlay layer for the table page-break bands. It lives INSIDE the zoomed .paper
(filling it via inset:0) so the bands are scaled by the same CSS `zoom` as the
.tiptap page background — no sub-pixel seam at fractional zoom. pointer-events:none
keeps the editor clickable. The z-index must clear the column-/row-resize handles
/* Overlay layer for the table page-break bands. It lives inside .paper (filling it
via inset:0) so the bands are scaled by the same transform as the .tiptap page
background — no sub-pixel seam at fractional zoom. pointer-events:none keeps the
editor clickable. The z-index must clear the column-/row-resize handles
(z-index:20 in editor.css): a too-tall cell is one continuous DOM box, so its resize
handle spans every page gap it crosses; the band masks that handle in the gap just
like it masks the table's borders bleeding through the page margins. */
+31 -42
View File
@@ -97,12 +97,9 @@ type VMargins = {
};
// Reads the vertical page margins + page height (px) from the --user-* custom
// props the Layout panel writes via applyMarginVars (pageMargins.ts) and
// applyOrientationVars (pageOrientation.ts). These are plain px strings in
// document coordinates — unaffected by the ancestor CSS `zoom`, unlike
// getComputedStyle padding — so they pair directly with the unscaled offsetTop
// measurements below. Side margins don't affect pagination (they change line
// wrapping, which we already measure from the live DOM), so we ignore them.
// props the Layout panel writes (pageMargins.ts / pageOrientation.ts). These are
// document-px values, so they pair directly with the offsetTop measurements below.
// Side margins don't affect pagination (only line wrapping), so we ignore them.
function readVerticalMargins(dom: HTMLElement): VMargins {
const cs = getComputedStyle(dom);
const top = parseFloat(cs.getPropertyValue('--user-margin-top'));
@@ -224,20 +221,13 @@ 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;
// The display scale (.paper has `transform: scale()`): the ratio of .tiptap's
// painted height to its unscaled offsetHeight. findLineSplit uses it to convert
// scaled glyph rects back to document px.
function getScaleFactor(): number {
const r = editorView.dom.getBoundingClientRect();
const h = editorView.dom.offsetHeight;
return h ? r.height / h : 1;
}
function previousNonSpacerSibling(el: HTMLElement): HTMLElement | null {
@@ -331,7 +321,7 @@ export const PageBreaks = Extension.create({
function findLineSplit(
el: HTMLElement,
overflowDistance: number,
zoom: number,
scale: number,
): { naturalLineTop: number; docPos: number } | null {
const lines = getLineRects(el);
if (lines.length === 0) return null;
@@ -357,7 +347,7 @@ export const PageBreaks = Extension.create({
for (const sp of intraSpacers) {
if (sp.viewportTop < viewportY) dropped += sp.height;
}
return (viewportY - elRect.top) / zoom - dropped;
return (viewportY - elRect.top) / scale - dropped;
}
// Line-box bottom in natural coords. Range.getClientRects() reports
@@ -418,29 +408,27 @@ export const PageBreaks = Extension.create({
return { naturalLineTop, docPos: lo };
}
function collectLeaves(contentHeight: number, zoom: number): Leaf[] {
function collectLeaves(contentHeight: number): Leaf[] {
const dom = editorView.dom;
const domRect = dom.getBoundingClientRect();
const leaves: Leaf[] = [];
// cumulativeSpacerHeight accumulates spacer offsetHeights (unscaled
// layout px), matching the offsetHeight-based naturalHeight.
let cumulativeSpacerHeight = 0;
// A leaf's top within .tiptap, in unscaled document px. We read
// getBoundingClientRect (the real painted geometry) and divide by the CSS
// `zoom` on .paper — the same scaled→unscaled conversion findLineSplit and
// the CSS page background use. Summing offsetTop up the offsetParent chain
// (the previous approach) drifts from the rendered layout under zoom —
// notably through a table's nested offsetParents — so breaks landed a few
// percent off, accumulating down the page until content was clipped.
// offsetHeight stays the source for heights: it's a single-element read (no
// chain to drift) and integer-stable, avoiding per-keystroke spacer shake.
// Round to whole px: spacer heights / cumulativeSpacerHeight are integers, so
// an integer top keeps the model integer-stable (no sub-pixel jitter from the
// /zoom of a device-rounded spacer flipping a break on every keystroke), at a
// ≤0.5px cost that's immaterial next to the drift this replaces.
// A leaf's border-box top within .tiptap, in document px. Summing offsetTop
// up the offsetParent chain is unaffected by the `transform: scale()` on
// .paper, so the value is the same at every display zoom. offsetTop includes
// each offsetParent's top padding; the chain ends at .tiptap, whose padding-
// top is the page top margin, so the sum already uses the page-cycle origin
// (page top = 0, content starts at the top margin).
function topWithin(el: HTMLElement): number {
return Math.round((el.getBoundingClientRect().top - domRect.top) / zoom);
let top = 0;
let node: HTMLElement | null = el;
while (node && node !== dom) {
top += node.offsetTop;
node = node.offsetParent as HTMLElement | null;
}
return top;
}
// Emit one atomic leaf per table row so the table can break between
@@ -616,8 +604,8 @@ export const PageBreaks = Extension.create({
const marginLeft = Number.isFinite(mlRaw) ? mlRaw : 80;
const contentWidth = (Number.isFinite(pwRaw) ? pwRaw : 794) - marginLeft - (Number.isFinite(mrRaw) ? mrRaw : 80);
const zoom = getZoomFactor();
const leaves = collectLeaves(CONTENT_HEIGHT, zoom);
const scale = getScaleFactor();
const leaves = collectLeaves(CONTENT_HEIGHT);
let cumulativeShift = 0;
// Per-cell flow bracketing for a too-tall row (see Leaf flow markers): each
@@ -698,7 +686,7 @@ export const PageBreaks = Extension.create({
reason = 'atomic-too-tall-no-push';
}
} else {
const split = findLineSplit(leaf.el, contentEnd - effectiveTop, zoom);
const split = findLineSplit(leaf.el, contentEnd - effectiveTop, scale);
if (split === null) {
if (leaf.naturalHeight <= CONTENT_HEIGHT) {
spacerHeight = pageContentStart(page + 1, vm.top, CYCLE_PX) - effectiveTop;
@@ -863,9 +851,10 @@ export const PageBreaks = Extension.create({
gap: PAGE_GAP,
}));
// docHeight (document px) lets Editor.svelte size the scaled scroll footprint.
dom.dispatchEvent(new CustomEvent('pm-pagecount', {
bubbles: true,
detail: { numPages, tableBreakBands },
detail: { numPages, docHeight: targetHeight, tableBreakBands },
}));
lastSnapshot = {
+18 -2
View File
@@ -7,13 +7,22 @@
position: relative;
}
/* Reserves the SCALED footprint of the transformed .paper (a CSS transform takes
no layout space of its own), so the scrollbars and horizontal centering reflect
the zoomed page. Its width/height are sized in JS (Editor.svelte). */
.paper-scaler {
margin: 0 auto;
}
/* A4 paper container (794px = 210mm at 96dpi by default; --user-page-width is
set from the Layout panel and swaps to 1123px in landscape — pageOrientation.ts) */
.paper {
width: var(--user-page-width, 794px);
margin: 0 auto;
/* Zoom is a `transform: scale()` set inline (Editor.svelte); pin the origin so the
page scales down/right from the top-left of its reserved footprint. */
transform-origin: top left;
/* Positioning context for the table page-break overlay (.band-layer in
Editor.svelte), which lives inside .paper so the ancestor `zoom` scales it
Editor.svelte), which lives inside .paper so the same transform scales it
identically to the .tiptap page background. */
position: relative;
}
@@ -354,9 +363,16 @@
overflow: visible;
}
/* Never scale for print — the real page size comes from @page below. */
.paper-scaler {
width: auto;
height: auto;
}
.paper {
width: 100%;
max-width: none;
transform: none;
}
.paper .tiptap {