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.
This commit is contained in:
2026-05-22 20:30:29 +03:00
parent bbc12ee4be
commit 49b6c5191e
2 changed files with 60 additions and 5 deletions

View File

@@ -61,8 +61,41 @@
return { w: 0, h: 0 }; 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<typeof setTimeout> | undefined;
function pulse() {
animate = true;
clearTimeout(animTimer);
animTimer = setTimeout(() => (animate = false), 180);
}
function zoomBy(factor: number) { 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. */ /** Scale the diagram so it fits within the visible canvas. */
@@ -70,15 +103,17 @@
if (!canvasEl || !svg) return; if (!canvasEl || !svg) return;
const { w, h } = svgDims(svg); const { w, h } = svgDims(svg);
if (!w || !h) return; if (!w || !h) return;
pulse();
const margin = store.theme !== 'dark' ? 72 : 32; const margin = store.theme !== 'dark' ? 72 : 32;
const cw = canvasEl.clientWidth - margin; const cw = canvasEl.clientWidth - margin;
const ch = canvasEl.clientHeight - margin; const ch = canvasEl.clientHeight - margin;
if (cw <= 0 || ch <= 0) return; 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 }; pan = { x: 0, y: 0 };
} }
function reset() { function reset() {
pulse();
zoom = 1; zoom = 1;
pan = { x: 0, y: 0 }; pan = { x: 0, y: 0 };
} }
@@ -99,9 +134,18 @@
dragging = false; dragging = false;
} }
function onWheel(e: WheelEvent) { function onWheel(e: WheelEvent) {
// macOS pinch-to-zoom arrives as ctrl+wheel; ⌘/Ctrl+wheel zooms too.
if (e.ctrlKey || e.metaKey) { if (e.ctrlKey || e.metaKey) {
e.preventDefault(); 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 };
} }
} }
</script> </script>
@@ -143,6 +187,7 @@
<div <div
class="stage" class="stage"
class:paper={store.theme !== 'dark'} class:paper={store.theme !== 'dark'}
class:animate
style="transform: translate({pan.x}px, {pan.y}px) scale({zoom});" style="transform: translate({pan.x}px, {pan.y}px) scale({zoom});"
> >
{@html svg} {@html svg}
@@ -230,7 +275,14 @@
} }
.stage { .stage {
transform-origin: center center; 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 /* Light Mermaid themes are designed for a white page, so give them a
"paper" sheet; the dark theme renders directly on the dark canvas. */ "paper" sheet; the dark theme renders directly on the dark canvas. */

View File

@@ -15,7 +15,10 @@ function configure(theme: string) {
theme: theme as any, theme: theme as any,
securityLevel: 'loose', securityLevel: 'loose',
fontFamily: 'inherit', 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 },
}); });
} }