From c070c8ffa62c4c88d48f33fd18a7a40d55bb808a Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Fri, 26 Jun 2026 09:56:12 +0200 Subject: [PATCH] fix(editor): improve scroll animation interpolation (#11562) --- packages/excalidraw/scroll.ts | 55 ++++++++++++++++++++++++++++------- 1 file changed, 44 insertions(+), 11 deletions(-) diff --git a/packages/excalidraw/scroll.ts b/packages/excalidraw/scroll.ts index d6d6789b03..8ef5d218e7 100644 --- a/packages/excalidraw/scroll.ts +++ b/packages/excalidraw/scroll.ts @@ -103,11 +103,53 @@ const getTargetViewport = ( return { scrollX, scrollY, zoom: state.zoom }; }; +/** + * Interpolates the viewport from `from` to `target` at the (already-eased) + * blend amount `factor` (0 = `from`, 1 = `target`). + * + * Zoom is interpolated geometrically (so it feels uniform), and rather than + * tweening scrollX/scrollY directly we tween the *focal point* — the scene + * point under the viewport center — and derive scroll from it. Mixing a linear + * scroll with a geometric zoom makes the focal point swoop sideways + * mid-animation (most visible when zooming out); gliding the focal point keeps + * it steady. `width/2/zoom - scroll` is the inverse of `centerScrollOn` without + * offsets, so factor 0/1 land exactly on `from`/`target`. + */ +export const interpolateViewport = ({ + from, + target, + factor, +}: { + from: Pick; + target: Viewport; + factor: number; +}): Viewport => { + const zoom = (from.zoom.value * + Math.pow( + target.zoom.value / from.zoom.value, + factor, + )) as NormalizedZoomValue; + + const fromCenterX = from.width / 2 / from.zoom.value - from.scrollX; + const fromCenterY = from.height / 2 / from.zoom.value - from.scrollY; + const toCenterX = from.width / 2 / target.zoom.value - target.scrollX; + const toCenterY = from.height / 2 / target.zoom.value - target.scrollY; + + const centerX = fromCenterX + (toCenterX - fromCenterX) * factor; + const centerY = fromCenterY + (toCenterY - fromCenterY) * factor; + + return { + scrollX: from.width / 2 / zoom - centerX, + scrollY: from.height / 2 / zoom - centerY, + zoom: { value: zoom }, + }; +}; + /** Eases the viewport from its current position to `target` over `duration`, * driving the transition through the shared AnimationController so it doesn't * slow down other processes. */ const animateToViewport = ( - from: Pick, + from: Pick, target: Viewport, duration: number, onFrame: ( @@ -125,17 +167,8 @@ const animateToViewport = ( const factor = easeOut(clamp(progress, 0, 1)); onFrame({ + ...interpolateViewport({ from, target, factor }), shouldCacheIgnoreZoom: progress < 1, // ignore zoom caching while animating - scrollX: from.scrollX + (target.scrollX - from.scrollX) * factor, - scrollY: from.scrollY + (target.scrollY - from.scrollY) * factor, - // zoom interpolates geometrically so the transition feels natural - zoom: { - value: (from.zoom.value * - Math.pow( - target.zoom.value / from.zoom.value, - factor, - )) as NormalizedZoomValue, - }, }); // returning a falsy value signals the AnimationController to remove the