Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 86 additions & 19 deletions packages/react/src/internal/use-pan-zoom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,18 @@ import {
} from '@canvas-harness/core'
import { useEffect, useRef } from 'react'

/**
* Minimal shape of WebKit's non-standard `GestureEvent` (Safari /
* WKWebView), which isn't in the standard DOM lib types. `scale` is
* cumulative since the `gesturestart` that opened the gesture (where it
* is `1.0`), NOT a per-event delta.
*/
interface GestureLikeEvent extends Event {
readonly scale: number
readonly clientX: number
readonly clientY: number
}

/**
* Wires up wheel zoom, middle-button / spacebar pan, and (phase 11)
* touch pinch-zoom + two-finger pan + pointer-type write-through.
Expand Down Expand Up @@ -172,6 +184,18 @@ export const usePanZoom = (
rafId = requestAnimationFrame(flushPending)
}

// Accumulate a zoom factor at a screen anchor. Shared by the wheel,
// touch-pinch and WebKit gesture paths so the finite/positive guard
// and the accumulate contract live in exactly one place. Callers own
// their own pulseMotion + schedule (they differ: the wheel path also
// pans and schedules once at the end; the touch path drives its own
// motion mode via pointer up/down).
const queueZoom = (factor: number, anchor: { x: number; y: number }): void => {
if (!Number.isFinite(factor) || factor <= 0) return
pendingZoomFactor *= factor
pendingZoomAnchor = anchor
}

const isEditing = (): boolean => store.getInteractionState().mode === 'editing'

const screenFromClient = (clientX: number, clientY: number): { x: number; y: number } => {
Expand Down Expand Up @@ -212,8 +236,7 @@ export const usePanZoom = (
// a 63% drop in a single click.
const factor =
Math.abs(e.deltaY) >= 100 ? (e.deltaY > 0 ? 1 / 1.1 : 1.1) : Math.exp(-e.deltaY * 0.01)
pendingZoomFactor *= factor
pendingZoomAnchor = screenFromClient(e.clientX, e.clientY)
queueZoom(factor, screenFromClient(e.clientX, e.clientY))
pulseMotion('zooming')
} else {
pendingDx += -e.deltaX
Expand Down Expand Up @@ -289,11 +312,7 @@ export const usePanZoom = (
const [a, b] = pts
const dist = Math.hypot(a!.x - b!.x, a!.y - b!.y)
const mid = { x: (a!.x + b!.x) / 2, y: (a!.y + b!.y) / 2 }
const factor = dist / lastPinchDistance
if (Number.isFinite(factor) && factor > 0) {
pendingZoomFactor *= factor
pendingZoomAnchor = mid
}
queueZoom(dist / lastPinchDistance, mid)
pendingDx += mid.x - lastPinchMidpoint.x
pendingDy += mid.y - lastPinchMidpoint.y
lastPinchDistance = dist
Expand Down Expand Up @@ -359,20 +378,68 @@ export const usePanZoom = (
if (e.code === 'Space') panActivatedBySpace = false
}

// Safari-only `gesturestart`/`gesturechange`/`gestureend` fire for
// native trackpad pinches *before* (and sometimes instead of) the
// ctrlKey-wheel variant the harness already handles. Without
// suppressing them the browser's default page-zoom wins. No-op on
// Chromium/Firefox where these events never fire.
const onGesture = (e: Event) => e.preventDefault()
// WebKit (Safari / WKWebView) encodes a trackpad pinch as
// `gesturestart`/`gesturechange`/`gestureend` (GestureEvent, with a
// cumulative `scale`) and — unlike Chromium — does NOT also emit a
// `ctrlKey` wheel, so the wheel branch above never sees the pinch on
// WebKit. Translate `scale` into the same accumulate-and-flush zoom
// the wheel path uses, anchored at the gesture point. All three stay
// `preventDefault()`'d to suppress WebKit's native page-magnification.
// These events never fire on Chromium/Firefox, so this is inert there.
//
// `gestureSeeded` tracks whether the current gesture has a valid
// base `scale` yet. It is NOT used to gate the wheel path (no engine
// emits both gesture events and a ctrlKey wheel for one pinch), only
// to keep the cumulative-scale base fresh across a start we may have
// missed or bailed on.
let gestureBaseScale = 1
let gestureSeeded = false
const onGestureStart = (e: Event) => {
e.preventDefault()
if (isEditing()) return
// WebKit resets `scale` to 1.0 at each gesturestart.
gestureBaseScale = (e as GestureLikeEvent).scale
gestureSeeded = true
}
const onGestureChange = (e: Event) => {
e.preventDefault()
if (isEditing()) return
// On touchscreen WebKit (iOS / iPadOS) a pinch fires BOTH gesture*
// events AND touch pointers; the pointer pinch path already owns
// that case, so defer to it here to avoid applying zoom twice.
// A trackpad pinch (desktop Safari / WKWebView) has no active
// touches, so this path still runs there.
if (activeTouches.size >= 2) return
const ge = e as GestureLikeEvent
if (!gestureSeeded) {
// No fresh gesturestart (missed, or bailed while editing). Seed
// the base from this event and wait for the next tick to derive
// a factor, so a stale base can't cause a spurious zoom jump.
gestureBaseScale = ge.scale
gestureSeeded = true
return
}
// `scale` is cumulative since gesturestart — convert to a per-event
// factor. Applying `scale` raw each tick double-applies and
// over-zooms.
const factor = ge.scale / gestureBaseScale
gestureBaseScale = ge.scale
queueZoom(factor, screenFromClient(ge.clientX, ge.clientY))
pulseMotion('zooming')
schedule()
}
const onGestureEnd = (e: Event) => {
e.preventDefault()
gestureSeeded = false
}
el.addEventListener('wheel', onWheel, { passive: false })
el.addEventListener('pointerdown', onPointerDown)
el.addEventListener('pointermove', onPointerMove)
el.addEventListener('pointerup', onPointerUp)
el.addEventListener('pointercancel', onPointerCancel)
el.addEventListener('gesturestart', onGesture)
el.addEventListener('gesturechange', onGesture)
el.addEventListener('gestureend', onGesture)
el.addEventListener('gesturestart', onGestureStart)
el.addEventListener('gesturechange', onGestureChange)
el.addEventListener('gestureend', onGestureEnd)
window.addEventListener('keydown', onKeyDown)
window.addEventListener('keyup', onKeyUp)
return () => {
Expand All @@ -381,9 +448,9 @@ export const usePanZoom = (
el.removeEventListener('pointermove', onPointerMove)
el.removeEventListener('pointerup', onPointerUp)
el.removeEventListener('pointercancel', onPointerCancel)
el.removeEventListener('gesturestart', onGesture)
el.removeEventListener('gesturechange', onGesture)
el.removeEventListener('gestureend', onGesture)
el.removeEventListener('gesturestart', onGestureStart)
el.removeEventListener('gesturechange', onGestureChange)
el.removeEventListener('gestureend', onGestureEnd)
window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('keyup', onKeyUp)
// motion-end rAF poll exits on its own when motionEndPolling
Expand Down
207 changes: 207 additions & 0 deletions packages/react/tests/use-pan-zoom.browser.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
/**
* Browser-mode tests for WebKit trackpad pinch-zoom.
*
* WebKit (Safari / WKWebView) encodes a trackpad pinch as
* `gesturestart`/`gesturechange`/`gestureend` (GestureEvent with a
* cumulative `scale`) and does NOT emit the `ctrlKey` wheel that
* Chromium does — so `usePanZoom` handles those gesture events
* directly. Chromium (what this browser suite runs in) never fires
* GestureEvent natively, but the handlers are wired via
* `addEventListener('gesture*')`, so we drive them with synthetic
* events. This validates the handler math + wiring, not real Safari
* behaviour (that needs a manual Safari pass).
*
* Strategy: mount <Canvas tool="select">, dispatch a
* gesturestart → gesturechange sequence on the canvas host, flush the
* rAF the zoom is coalesced through, then read camera from the store.
*/
import { type CanvasStore, asClientId, createCanvasStore } from '@canvas-harness/core'
import { StrictMode, act } from 'react'
import { createRoot } from 'react-dom/client'
import { describe, expect, test } from 'vitest'
import { Canvas, CanvasProvider } from '../src'
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true

const VIEWPORT_W = 800
const VIEWPORT_H = 600

const mountCanvas = async (store: CanvasStore) => {
const container = document.createElement('div')
container.style.position = 'fixed'
container.style.left = '0px'
container.style.top = '0px'
container.style.width = `${VIEWPORT_W}px`
container.style.height = `${VIEWPORT_H}px`
document.body.appendChild(container)
const root = createRoot(container)
await act(async () => {
root.render(
<StrictMode>
<CanvasProvider store={store}>
<Canvas tool="select" />
</CanvasProvider>
</StrictMode>,
)
})
// Yield so child effects (renderer mount, pan-zoom listeners) attach.
await new Promise(resolve => setTimeout(resolve, 0))
const wrap = container.querySelector('[data-canvas-host]') as HTMLDivElement
if (!wrap) throw new Error('canvas host not found')
return {
wrap,
cleanup: () => act(async () => root.unmount()).then(() => container.remove()),
}
}

/** Build a synthetic WebKit GestureEvent (jsdom/chromium have no ctor). */
const gestureEvent = (
type: 'gesturestart' | 'gesturechange' | 'gestureend',
scale: number,
client: { x: number; y: number },
): Event => {
const e = new Event(type, { bubbles: true, cancelable: true })
Object.assign(e, { scale, clientX: client.x, clientY: client.y })
return e
}

/** Waits two rAF ticks so the coalesced zoom flush has definitely run. */
const nextFrames = () =>
new Promise<void>(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve())))

describe('WebKit pinch-zoom (gesture events)', () => {
test('gesturechange with scale > 1 zooms in toward the anchor', async () => {
const store = createCanvasStore({ clientId: asClientId('test') })
const m = await mountCanvas(store)
const rect = m.wrap.getBoundingClientRect()
const anchor = { x: rect.left + 200, y: rect.top + 150 }
const startZoom = store.getCamera().z

await act(async () => {
m.wrap.dispatchEvent(gestureEvent('gesturestart', 1, anchor))
m.wrap.dispatchEvent(gestureEvent('gesturechange', 1.5, anchor))
})
await act(async () => {
await nextFrames()
})

// scale 1 → 1.5 is a 1.5x zoom (within the per-frame clamp of 2x).
expect(store.getCamera().z).toBeCloseTo(startZoom * 1.5, 5)
await m.cleanup()
})

test('scale is treated as cumulative, not per-event (no double-apply)', async () => {
const store = createCanvasStore({ clientId: asClientId('test') })
const m = await mountCanvas(store)
const rect = m.wrap.getBoundingClientRect()
const anchor = { x: rect.left + 400, y: rect.top + 300 }
const startZoom = store.getCamera().z

// Two ticks of the SAME gesture: cumulative scale 1 → 1.2 → 1.44.
// Net zoom must be 1.44x, not 1.2 * 1.44 (which is what applying
// `scale` raw each tick would produce).
await act(async () => {
m.wrap.dispatchEvent(gestureEvent('gesturestart', 1, anchor))
m.wrap.dispatchEvent(gestureEvent('gesturechange', 1.2, anchor))
m.wrap.dispatchEvent(gestureEvent('gesturechange', 1.44, anchor))
m.wrap.dispatchEvent(gestureEvent('gestureend', 1.44, anchor))
})
await act(async () => {
await nextFrames()
})

expect(store.getCamera().z).toBeCloseTo(startZoom * 1.44, 5)
await m.cleanup()
})

test('pinch enters the "zooming" interaction mode (drives motion LOD)', async () => {
const store = createCanvasStore({ clientId: asClientId('test') })
const m = await mountCanvas(store)
const rect = m.wrap.getBoundingClientRect()
const anchor = { x: rect.left + 100, y: rect.top + 100 }

await act(async () => {
m.wrap.dispatchEvent(gestureEvent('gesturestart', 1, anchor))
m.wrap.dispatchEvent(gestureEvent('gesturechange', 1.3, anchor))
})
await act(async () => {
await nextFrames()
})
expect(store.getInteractionState().mode).toBe('zooming')
await m.cleanup()
})

test('gesture events defer to the touch-pinch path when two touches are active', async () => {
// iOS / iPadOS fire GestureEvents AND touch pointers for the same
// pinch. With two touches down, the gesture path must NOT also apply
// zoom (the pointer pinch path owns it) — otherwise zoom double-applies.
const store = createCanvasStore({ clientId: asClientId('test') })
const m = await mountCanvas(store)
const rect = m.wrap.getBoundingClientRect()
const anchor = { x: rect.left + 200, y: rect.top + 150 }
const startZoom = store.getCamera().z

// Synthetic pointers aren't real active pointers, so the hook's
// setPointerCapture (fired when the 2nd touch lands) would throw.
// Stub the capture API — irrelevant to what this test asserts.
m.wrap.setPointerCapture = () => {}
m.wrap.releasePointerCapture = () => {}
m.wrap.hasPointerCapture = () => false

const touchDown = (pointerId: number, x: number) =>
m.wrap.dispatchEvent(
new PointerEvent('pointerdown', {
pointerType: 'touch',
pointerId,
clientX: rect.left + x,
clientY: rect.top + 150,
bubbles: true,
cancelable: true,
}),
)

await act(async () => {
touchDown(1, 180)
touchDown(2, 220) // activeTouches.size === 2 → touch pinch owns zoom
m.wrap.dispatchEvent(gestureEvent('gesturestart', 1, anchor))
m.wrap.dispatchEvent(gestureEvent('gesturechange', 1.5, anchor))
})
await act(async () => {
await nextFrames()
})

// No touch *move* happened, so the pointer path zoomed 0; the gesture
// path must have deferred → camera unchanged (no double-apply).
expect(store.getCamera().z).toBeCloseTo(startZoom, 5)
await m.cleanup()
})

test('a gesturechange with no preceding gesturestart seeds the base without jumping', async () => {
// If gesturestart is missed/bailed, the first change must only seed
// the cumulative-scale base (no zoom), so a stale base can't snap the
// camera. The next change then derives a clean factor from that seed.
const store = createCanvasStore({ clientId: asClientId('test') })
const m = await mountCanvas(store)
const rect = m.wrap.getBoundingClientRect()
const anchor = { x: rect.left + 300, y: rect.top + 200 }
const startZoom = store.getCamera().z

await act(async () => {
// No gesturestart. First change (scale 1.5) seeds base = 1.5.
m.wrap.dispatchEvent(gestureEvent('gesturechange', 1.5, anchor))
})
await act(async () => {
await nextFrames()
})
expect(store.getCamera().z).toBeCloseTo(startZoom, 5) // seeded, not jumped

await act(async () => {
// Second change (scale 1.8) → factor 1.8 / 1.5 = 1.2.
m.wrap.dispatchEvent(gestureEvent('gesturechange', 1.8, anchor))
})
await act(async () => {
await nextFrames()
})
expect(store.getCamera().z).toBeCloseTo(startZoom * 1.2, 5)
await m.cleanup()
})
})
Loading