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:
@@ -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<typeof setTimeout> | 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 };
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -143,6 +187,7 @@
|
||||
<div
|
||||
class="stage"
|
||||
class:paper={store.theme !== 'dark'}
|
||||
class:animate
|
||||
style="transform: translate({pan.x}px, {pan.y}px) scale({zoom});"
|
||||
>
|
||||
{@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. */
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user