feat: switch from css zoom (rerunning layout at every scale) to pure view transform (more stable)
This commit is contained in:
1 parent
8d91801491
commit
c641dda4e0
3 files changed
+109
-73
No files matched your search
@@ -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. */
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user