Skip to content

fix(react): WebKit trackpad pinch-zoom via gesture events - #35

Merged
winlp4ever merged 2 commits into
mainfrom
fix/webkit-pinch-zoom
Aug 3, 2026
Merged

fix(react): WebKit trackpad pinch-zoom via gesture events#35
winlp4ever merged 2 commits into
mainfrom
fix/webkit-pinch-zoom

Conversation

@winlp4ever

Copy link
Copy Markdown
Owner

Problem

Two-finger trackpad pinch-zoom works in Chrome but does nothing in Safari and the Dim0 desktop app (Tauri → WKWebView).

Root cause: the library derives zoom only from wheel events carrying ctrlKey — Chromium's encoding of a trackpad pinch. WebKit doesn't do that: it fires gesturestart/gesturechange/gestureend (GestureEvent, cumulative scale) and emits no ctrlKey wheel. The gesture handlers here only preventDefault()'d, so on WebKit the pinch was swallowed — no canvas zoom and no native page zoom either.

Fix

packages/react/src/internal/use-pan-zoom.ts — translate gesturechange's scale into the same accumulate-and-flush zoom pipeline the wheel path already uses (pendingZoomFactor / pendingZoomAnchorzoomAtScreenPoint + clampZoom), anchored at the gesture point, and drive motion-LOD via pulseMotion('zooming').

  • scale is cumulative since gesturestart (=1.0), so we track a base and divide to get the per-event factor — applying it raw would double-apply and over-zoom.
  • A gestureActive flag suppresses any stray ctrlKey wheel during a native pinch (double-apply guard).
  • All three gesture events stay preventDefault()'d to suppress WebKit's native page-magnification.
  • Chromium/Firefox never fire these events, so the path is inert there and the existing wheel+ctrl zoom is unchanged.

Tests

New tests/use-pan-zoom.browser.test.tsx dispatches synthetic gesture events (chromium doesn't fire them natively but the handlers are wired via addEventListener) and asserts:

  1. scale 1 → 1.5 produces a 1.5× zoom,
  2. cumulative scale (1 → 1.2 → 1.44) yields 1.44× net — not double-applied,
  3. a pinch enters the zooming interaction mode (motion-LOD).

All green: full react unit + browser suites (15/15), repo lint + typecheck pass.

Still needs a human

Chromium can't fire real GestureEvents, so the automated test covers handler math/wiring, not actual Safari behaviour. Manual pass: desktop Safari, pinch → should zoom toward the cursor; confirm Chrome unchanged; then the Dim0 desktop app once a fixed @canvas-harness/* is published.

Safari / WKWebView encode a trackpad pinch as gesturestart/change/end
(GestureEvent, cumulative scale) and do NOT emit the ctrlKey wheel that
Chromium does, so pinch-zoom never worked there — the gesture handlers
only preventDefault'd. Translate the cumulative scale into the same
accumulate-and-flush zoom the wheel path already uses, anchored at the
gesture point, so it goes through the identical clamp/limits and drives
motion-LOD via pulseMotion('zooming').

A gestureActive flag suppresses any stray ctrlKey wheel during a native
pinch as a double-apply guard. Chromium/Firefox never fire these events,
so the path is inert there and the existing wheel+ctrl zoom is unchanged.

Adds a browser test that dispatches synthetic gesture events (chromium
doesn't fire them natively) and asserts zoom magnitude, cumulative-scale
handling (no double-apply), and the zooming interaction mode. Real Safari
still needs a manual pass.
… dedup

Follow-up to the WebKit pinch-zoom handlers:

- Defer the gesture* path when two touches are active (activeTouches.size
  >= 2). iOS/iPadOS fire BOTH GestureEvents and touch pointers for one
  pinch; the pointer pinch path already owns that case, so the gesture
  path must not also apply zoom (was double-applying on touchscreens).
- Self-heal the cumulative-scale base: a gesturechange with no fresh
  gesturestart (missed, or bailed while editing) now seeds the base and
  skips a frame instead of dividing by a stale base and snapping zoom.
- Drop the gestureActive flag + its onWheel guard. It guarded against an
  engine firing both gesture events and a ctrlKey wheel for one pinch,
  which no engine does — while risking a stuck-true state that swallowed
  wheel input, and suppressing two-finger pan wheels. Removing it fixes
  both without weakening real coverage.
- Extract queueZoom(factor, anchor) so the finite/positive guard +
  accumulate contract lives in one place (was triplicated across wheel,
  touch and gesture paths).

Adds tests for the touch-defer and stale-base cases. Full react suite
(17 browser + unit), repo lint + typecheck all pass.
@winlp4ever
winlp4ever merged commit d7b9455 into main Aug 3, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant