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 };
|
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. */
|
||||||
|
|||||||
@@ -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 },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user