Fix trackpad pinch zoom in Safari#4709
Conversation
Zoom was dead on macOS Safari with a trackpad. Chrome and Firefox synthesize a ctrl+wheel event for a trackpad pinch, which InputHandler.onScroll already handles; WebKit fires its non-standard gesturestart/gesturechange/gestureend events instead, and nothing converted those into a game zoom. Handle the gesture events on the input overlay and convert them to a ZoomEvent. GestureEvent.scale is cumulative from gesturestart, so the per-event ratio is scale / lastGestureScale. onZoom divides the camera scale by 1 + delta / DIVISOR, so inverting that reproduces the pinch ratio exactly. That divisor is now exported as ZOOM_DELTA_DIVISOR so the gesture math and the zoom sensitivity can't silently desync. The scale is advanced before the multi-pointer guard: if a pointer lifts mid-gesture, the next event must measure from the last scale, not from gesturestart, or it re-applies zoom the pointer path already handled.
WalkthroughSafari trackpad gestures are handled in ChangesSafari trackpad zoom
Estimated code review effort: 3 (Moderate) | ~20 minutes Sequence Diagram(s)sequenceDiagram
participant Safari
participant InputHandler
participant EventBus
participant TransformHandler
Safari->>InputHandler: Dispatch gesturestart
Safari->>InputHandler: Dispatch gesturechange with scale and focal point
InputHandler->>EventBus: Emit ZoomEvent
EventBus->>TransformHandler: Deliver zoom delta
TransformHandler->>TransformHandler: Apply zoom factor
Safari->>InputHandler: Dispatch gestureend
Possibly related PRs
Suggested reviewers: Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
🧹 Nitpick comments (1)
tests/client/InputHandlerGestureZoom.test.ts (1)
168-175: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winTest title contradicts its own setup.
The title says "which registers no pointers," but the test dispatches a
pointerDown(line 169). Presumably this verifies that a single pointer stays below the 2-pointer multi-touch threshold so gesture zoom still fires — worth renaming to something like "still zooms when only one pointer is down (below the multi-touch threshold)" to avoid confusing future debugging.✏️ Suggested title fix
- it("still zooms for a trackpad pinch, which registers no pointers", () => { + it("still zooms when only one pointer is down (below multi-touch threshold)", () => {🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@tests/client/InputHandlerGestureZoom.test.ts` around lines 168 - 175, Rename the test case containing dispatchPointerDown and the gesturestart/gesturechange assertions to accurately describe that zoom still occurs with only one pointer down, below the multi-touch threshold. Do not change the test setup or expectations.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@tests/client/InputHandlerGestureZoom.test.ts`:
- Around line 168-175: Rename the test case containing dispatchPointerDown and
the gesturestart/gesturechange assertions to accurately describe that zoom still
occurs with only one pointer down, below the multi-touch threshold. Do not
change the test setup or expectations.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: c1f85c52-b3fd-4fc9-858e-fe572a19fea9
📒 Files selected for processing (3)
src/client/InputHandler.tssrc/client/TransformHandler.tstests/client/InputHandlerGestureZoom.test.ts
Resolves #4664
Submitting directly as a small bug fix, per the exception in CONTRIBUTING.md.
Problem
Zoom is completely dead on macOS Safari with a trackpad.
Chrome and Firefox synthesize a
ctrl+wheelevent for a trackpad pinch, whichInputHandler.onScrollalready handles. WebKit fires its non-standardgesturestart/gesturechange/gestureendevents instead, so that branch neverran in Safari.
Nothing consumed those events either —
installSafariPinchZoomBlockeronly callspreventDefault()on them to stop the page zooming, on the assumption that thegame's own pinch runs on pointer events. That holds on a touchscreen, but a
trackpad pinch produces no second pointer. Net effect: page zoom blocked, game
zoom never triggered, pinch does nothing.
Fix
Handle the gesture events on the input overlay and convert them to a
ZoomEvent.GestureEvent.scaleis cumulative fromgesturestart, so the per-event ratio isscale / lastGestureScale.onZoomdivides the camera scale by1 + delta / 600, so inverting that gives the delta which reproduces the pinchratio exactly — a 1.2× pinch zooms 1.2×, with no tuning factor.
That
600was an unexplained literal inTransformHandler. Since the gesturemath has to invert it, it's now exported as
ZOOM_DELTA_DIVISORso retuning thezoom sensitivity can't silently desync the two.
The document-level blocker in
DisableSafariPinchZoom.tsstays as-is: it runs forthe page lifetime and also blocks page zoom on the lobby and menus, where no
InputHandlerexists.Testing
Confirmed by hand on macOS:
from the added listeners (Chrome doesn't fire GestureEvents).
Rebased on latest
main;npm testpasses (2221 + 235).Added
tests/client/InputHandlerGestureZoom.test.ts(12 tests): zoom directionboth ways, the ratio conversion checked against
onZoom's formula,cumulative-not-per-event scale, focal point, both guard paths,
preventDefault,and the pointer-lift transition below. jsdom has no
GestureEvent, so the testsfake one with a plain cancelable event — same approach as the existing
DisableSafariPinchZoom.test.ts.Note for reviewers
iOS dispatches gesture events on top of the low-level per-touch events — Apple's
Safari Web Content Guide
describes
GestureEvents as "also sent during amulti-touch sequence", intended as an alternative to processing individual
touches. So
onGestureChangebails out when two pointers are already tracked,leaving that case to the existing pointer path rather than double-applying. It
still advances the cumulative scale before that guard, so if a pointer lifts
mid-gesture the next event measures from the last scale rather than re-applying
zoom the pointer path already handled.