From 49b6c5191ece1fde66b2d9264e2c09cd33fa4819 Mon Sep 17 00:00:00 2001 From: Aleksey Shakhmatov Date: Fri, 22 May 2026 20:30:29 +0300 Subject: [PATCH] fix(preview): smooth trackpad zoom, crisp panning, correct fit on large diagrams - Pan no longer ghosts/stutters: drop the always-on transform transition (it interpolated against every mousemove) in favour of will-change layer promotion; a short transition is pulsed only for button zoom / fit. - Trackpad zoom is now continuous and cursor-anchored: zoom factor is exponential in wheel delta (clamped) instead of a fixed 10% per event, so macOS pinch feels smooth; mouse-wheel deltas are tamed by the same curve. Two-finger scroll now pans. - Fit no longer ends up tiny on big diagrams: render flowcharts with useMaxWidth:false so the SVG's laid-out size equals its viewBox, making the fit/zoom maths exact (previously the SVG was shrunk responsively *and* again by fit). Lower the zoom floor so very large diagrams can fit fully. --- src/lib/components/Preview.svelte | 60 ++++++++++++++++++++++++++++--- src/lib/mermaid.ts | 5 ++- 2 files changed, 60 insertions(+), 5 deletions(-) diff --git a/src/lib/components/Preview.svelte b/src/lib/components/Preview.svelte index 473d840..e7f5a67 100644 --- a/src/lib/components/Preview.svelte +++ b/src/lib/components/Preview.svelte @@ -61,8 +61,41 @@ return { w: 0, h: 0 }; } + const MIN_ZOOM = 0.04; + const MAX_ZOOM = 8; + const clampZoom = (z: number) => Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, z)); + + // A short transition is nice for button zooms but causes ghosting/stutter + // during drag-pan and pinch — so it's only enabled in bursts via pulse(). + let animate = $state(false); + let animTimer: ReturnType | undefined; + function pulse() { + animate = true; + clearTimeout(animTimer); + animTimer = setTimeout(() => (animate = false), 180); + } + function zoomBy(factor: number) { - zoom = Math.min(6, Math.max(0.1, zoom * factor)); + pulse(); + zoom = clampZoom(zoom * factor); + } + + /** Zoom toward a screen point so the diagram under the cursor stays put. */ + function zoomTo(next: number, clientX: number, clientY: number) { + const z2 = clampZoom(next); + if (!canvasEl || z2 === zoom) { + zoom = z2; + return; + } + const rect = canvasEl.getBoundingClientRect(); + // Cursor offset from the canvas centre, where the (centred) stage sits. + const mx = clientX - rect.left - rect.width / 2; + const my = clientY - rect.top - rect.height / 2; + pan = { + x: mx - (mx - pan.x) * (z2 / zoom), + y: my - (my - pan.y) * (z2 / zoom), + }; + zoom = z2; } /** Scale the diagram so it fits within the visible canvas. */ @@ -70,15 +103,17 @@ if (!canvasEl || !svg) return; const { w, h } = svgDims(svg); if (!w || !h) return; + pulse(); const margin = store.theme !== 'dark' ? 72 : 32; const cw = canvasEl.clientWidth - margin; const ch = canvasEl.clientHeight - margin; if (cw <= 0 || ch <= 0) return; - zoom = Math.max(0.1, Math.min(cw / w, ch / h, 3)); + zoom = Math.max(MIN_ZOOM, Math.min(cw / w, ch / h, 3)); pan = { x: 0, y: 0 }; } function reset() { + pulse(); zoom = 1; pan = { x: 0, y: 0 }; } @@ -99,9 +134,18 @@ dragging = false; } function onWheel(e: WheelEvent) { + // macOS pinch-to-zoom arrives as ctrl+wheel; ⌘/Ctrl+wheel zooms too. if (e.ctrlKey || e.metaKey) { e.preventDefault(); - zoomBy(e.deltaY < 0 ? 1.1 : 0.9); + // Continuous, exponential zoom — proportional to the gesture so the + // trackpad feels smooth instead of jumping in fixed 10% steps. The + // exp curve also tames a mouse wheel's large deltas. + const factor = Math.min(2, Math.max(0.5, Math.exp(-e.deltaY * 0.0015))); + zoomTo(zoom * factor, e.clientX, e.clientY); + } else { + // Two-finger scroll pans the canvas. + e.preventDefault(); + pan = { x: pan.x - e.deltaX, y: pan.y - e.deltaY }; } } @@ -143,6 +187,7 @@
{@html svg} @@ -230,7 +275,14 @@ } .stage { transform-origin: center center; - transition: transform 0.05s linear; + /* Promote to its own compositor layer so panning doesn't ghost/stutter. + No transition by default → drag-pan and pinch track the input 1:1. */ + will-change: transform; + backface-visibility: hidden; + } + /* Re-enabled in short bursts for button zoom / fit, never during gestures. */ + .stage.animate { + transition: transform 0.18s ease-out; } /* Light Mermaid themes are designed for a white page, so give them a "paper" sheet; the dark theme renders directly on the dark canvas. */ diff --git a/src/lib/mermaid.ts b/src/lib/mermaid.ts index f441b28..025ebf3 100644 --- a/src/lib/mermaid.ts +++ b/src/lib/mermaid.ts @@ -15,7 +15,10 @@ function configure(theme: string) { theme: theme as any, securityLevel: 'loose', fontFamily: 'inherit', - flowchart: { useMaxWidth: true, htmlLabels: true }, + // Render at the diagram's intrinsic pixel size (not responsive 100% width) + // so the preview's own zoom/fit maths stay exact — otherwise a large + // flowchart is scaled down by the SVG *and* again by fit, ending up tiny. + flowchart: { useMaxWidth: false, htmlLabels: true }, }); }