diff --git a/packages/react/src/internal/use-pan-zoom.ts b/packages/react/src/internal/use-pan-zoom.ts index 683ed8e..ba11860 100644 --- a/packages/react/src/internal/use-pan-zoom.ts +++ b/packages/react/src/internal/use-pan-zoom.ts @@ -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. @@ -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 } => { @@ -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 @@ -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 @@ -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 () => { @@ -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 diff --git a/packages/react/tests/use-pan-zoom.browser.test.tsx b/packages/react/tests/use-pan-zoom.browser.test.tsx new file mode 100644 index 0000000..1cc5697 --- /dev/null +++ b/packages/react/tests/use-pan-zoom.browser.test.tsx @@ -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 , 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( + + + + + , + ) + }) + // 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(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() + }) +})