[fix] pinch events (#1979)
This PR improves pinch events on touch screens. It tries to avoid zooming unless we're sure that the user is zooming (or at least, that they've started zooming). It removes behavior that snaps the pinch to a certain zoom (e.g. 100%, 50%). ### Change Type - [x] `patch` — Bug fix ### Test Plan 1. On iPad, try to two-finger pan without zooming. 2. Perform a zoom. 3. Perform a two-finger pan, then a zoom. ### Release Notes - Improve pinch gesture events.
This commit is contained in:
parent
f73bf9a7fe
commit
401075d719
2 changed files with 98 additions and 71 deletions
|
@ -8568,34 +8568,6 @@ export class Editor extends EventEmitter<TLEventMap> {
|
||||||
this.setSelectedShapes(this._selectedShapeIdsAtPointerDown, { squashing: true })
|
this.setSelectedShapes(this._selectedShapeIdsAtPointerDown, { squashing: true })
|
||||||
this._selectedShapeIdsAtPointerDown = []
|
this._selectedShapeIdsAtPointerDown = []
|
||||||
|
|
||||||
const {
|
|
||||||
camera: { x: cx, y: cy, z: cz },
|
|
||||||
} = this
|
|
||||||
|
|
||||||
let zoom: number | undefined
|
|
||||||
|
|
||||||
if (cz > 0.9 && cz < 1.05) {
|
|
||||||
zoom = 1
|
|
||||||
} else if (cz > 0.49 && cz < 0.505) {
|
|
||||||
zoom = 0.5
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cz > this._pinchStart - 0.1 && cz < this._pinchStart + 0.05) {
|
|
||||||
zoom = this._pinchStart
|
|
||||||
}
|
|
||||||
|
|
||||||
if (zoom !== undefined) {
|
|
||||||
const { x, y } = this.viewportScreenCenter
|
|
||||||
this.setCamera(
|
|
||||||
{
|
|
||||||
x: cx + (x / zoom - x) - (x / cz - x),
|
|
||||||
y: cy + (y / zoom - y) - (y / cz - y),
|
|
||||||
z: zoom,
|
|
||||||
},
|
|
||||||
{ duration: 100 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this._didPinch) {
|
if (this._didPinch) {
|
||||||
this._didPinch = false
|
this._didPinch = false
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
import type { AnyHandlerEventTypes, EventTypes, GestureKey, Handler } from '@use-gesture/core/types'
|
import type { AnyHandlerEventTypes, EventTypes, GestureKey, Handler } from '@use-gesture/core/types'
|
||||||
import { createUseGesture, pinchAction, wheelAction } from '@use-gesture/react'
|
import { createUseGesture, pinchAction, wheelAction } from '@use-gesture/react'
|
||||||
import throttle from 'lodash.throttle'
|
|
||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
import { TLWheelEventInfo } from '../editor/types/event-types'
|
import { TLWheelEventInfo } from '../editor/types/event-types'
|
||||||
import { Vec2d } from '../primitives/Vec2d'
|
import { Vec2d } from '../primitives/Vec2d'
|
||||||
|
@ -8,6 +7,40 @@ import { preventDefault } from '../utils/dom'
|
||||||
import { normalizeWheel } from '../utils/normalizeWheel'
|
import { normalizeWheel } from '../utils/normalizeWheel'
|
||||||
import { useEditor } from './useEditor'
|
import { useEditor } from './useEditor'
|
||||||
|
|
||||||
|
/*
|
||||||
|
|
||||||
|
# How does pinching work?
|
||||||
|
|
||||||
|
The pinching handler is fired under two circumstances:
|
||||||
|
- when a user is on a MacBook trackpad and is ZOOMING with a two-finger pinch
|
||||||
|
- when a user is on a touch device and is ZOOMING with a two-finger pinch
|
||||||
|
- when a user is on a touch device and is PANNING with two fingers
|
||||||
|
|
||||||
|
Zooming is much more expensive than panning (because it causes shapes to render),
|
||||||
|
so we want to be sure that we don't zoom while two-finger panning.
|
||||||
|
|
||||||
|
In order to do this, we keep track of a "pinchState", which is either:
|
||||||
|
- "zooming"
|
||||||
|
- "panning"
|
||||||
|
- "not sure"
|
||||||
|
|
||||||
|
If a user is on a trackpad, the pinchState will be set to "zooming".
|
||||||
|
|
||||||
|
If the user is on a touch screen, then we start in the "not sure" state and switch back and forth
|
||||||
|
between "zooming", "panning", and "not sure" based on what the user is doing with their fingers.
|
||||||
|
|
||||||
|
In the "not sure" state, we examine whether the user has moved the center of the gesture far enough
|
||||||
|
to suggest that they're panning; or else that they've moved their fingers further apart or closer
|
||||||
|
together enough to suggest that they're zooming.
|
||||||
|
|
||||||
|
In the "panning" state, we check whether the user's fingers have moved far enough apart to suggest
|
||||||
|
that they're zooming. If they have, we switch to the "zooming" state.
|
||||||
|
|
||||||
|
In the "zooming" state, we just stay zooming—it's not YET possible to switch back to panning.
|
||||||
|
|
||||||
|
todo: compare velocities of change in order to determine whether the user has switched back to panning
|
||||||
|
*/
|
||||||
|
|
||||||
type check<T extends AnyHandlerEventTypes, Key extends GestureKey> = undefined extends T[Key]
|
type check<T extends AnyHandlerEventTypes, Key extends GestureKey> = undefined extends T[Key]
|
||||||
? EventTypes[Key]
|
? EventTypes[Key]
|
||||||
: T[Key]
|
: T[Key]
|
||||||
|
@ -45,14 +78,14 @@ export function useGestureEvents(ref: React.RefObject<HTMLDivElement>) {
|
||||||
const editor = useEditor()
|
const editor = useEditor()
|
||||||
|
|
||||||
const events = React.useMemo(() => {
|
const events = React.useMemo(() => {
|
||||||
let pinchState = null as null | 'zooming' | 'panning'
|
let pinchState = 'not sure' as 'not sure' | 'zooming' | 'panning'
|
||||||
|
|
||||||
const onWheel: Handler<'wheel', WheelEvent> = ({ event }) => {
|
const onWheel: Handler<'wheel', WheelEvent> = ({ event }) => {
|
||||||
if (!editor.instanceState.isFocused) {
|
if (!editor.instanceState.isFocused) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
pinchState = null
|
pinchState = 'not sure'
|
||||||
|
|
||||||
if (isWheelEndEvent(Date.now())) {
|
if (isWheelEndEvent(Date.now())) {
|
||||||
// ignore wheelEnd events
|
// ignore wheelEnd events
|
||||||
|
@ -94,27 +127,27 @@ export function useGestureEvents(ref: React.RefObject<HTMLDivElement>) {
|
||||||
editor.dispatch(info)
|
editor.dispatch(info)
|
||||||
}
|
}
|
||||||
|
|
||||||
let initTouchDistance = 1
|
let initDistanceBetweenFingers = 1 // the distance between the two fingers when the pinch starts
|
||||||
let initZoom = 1
|
let initZoom = 1 // the browser's zoom level when the pinch starts
|
||||||
let currentZoom = 1
|
let currZoom = 1 // the current zoom level according to the pinch gesture recognizer
|
||||||
let currentTouchDistance = 0
|
let currDistanceBetweenFingers = 0
|
||||||
const initOrigin = new Vec2d()
|
const initPointBetweenFingers = new Vec2d()
|
||||||
const prevOrigin = new Vec2d()
|
const prevPointBetweenFingers = new Vec2d()
|
||||||
|
|
||||||
const onPinchStart: PinchHandler = (gesture) => {
|
const onPinchStart: PinchHandler = (gesture) => {
|
||||||
const elm = ref.current
|
const elm = ref.current
|
||||||
pinchState = null
|
pinchState = 'not sure'
|
||||||
|
|
||||||
const { event, origin, da } = gesture
|
const { event, origin, da } = gesture
|
||||||
|
|
||||||
if (event instanceof WheelEvent) return
|
if (event instanceof WheelEvent) return
|
||||||
if (!(event.target === elm || elm?.contains(event.target as Node))) return
|
if (!(event.target === elm || elm?.contains(event.target as Node))) return
|
||||||
|
|
||||||
prevOrigin.x = origin[0]
|
prevPointBetweenFingers.x = origin[0]
|
||||||
prevOrigin.y = origin[1]
|
prevPointBetweenFingers.y = origin[1]
|
||||||
initOrigin.x = origin[0]
|
initPointBetweenFingers.x = origin[0]
|
||||||
initOrigin.y = origin[1]
|
initPointBetweenFingers.y = origin[1]
|
||||||
initTouchDistance = da[0]
|
initDistanceBetweenFingers = da[0]
|
||||||
initZoom = editor.zoomLevel
|
initZoom = editor.zoomLevel
|
||||||
|
|
||||||
editor.dispatch({
|
editor.dispatch({
|
||||||
|
@ -128,20 +161,44 @@ export function useGestureEvents(ref: React.RefObject<HTMLDivElement>) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatePinchState = throttle((type: 'gesture' | 'touch') => {
|
// let timeout: any
|
||||||
if (pinchState === null) {
|
const updatePinchState = (isSafariTrackpadPinch: boolean) => {
|
||||||
const touchDistance = Math.abs(currentTouchDistance - initTouchDistance)
|
if (isSafariTrackpadPinch) {
|
||||||
const originDistance = Vec2d.Dist(initOrigin, prevOrigin)
|
pinchState = 'zooming'
|
||||||
|
}
|
||||||
|
|
||||||
if (type === 'gesture' && touchDistance) {
|
if (pinchState === 'zooming') {
|
||||||
pinchState = 'zooming'
|
return
|
||||||
} else if (type === 'touch' && touchDistance > 16) {
|
}
|
||||||
pinchState = 'zooming'
|
|
||||||
} else if (originDistance > 16) {
|
// Initial: [touch]-------origin-------[touch]
|
||||||
pinchState = 'panning'
|
// Current: [touch]-----------origin----------[touch]
|
||||||
|
// |----| |------------|
|
||||||
|
// originDistance ^ ^ touchDistance
|
||||||
|
|
||||||
|
// How far have the two touch points moved towards or away from eachother?
|
||||||
|
const touchDistance = Math.abs(currDistanceBetweenFingers - initDistanceBetweenFingers)
|
||||||
|
// How far has the point between the touches moved?
|
||||||
|
const originDistance = Vec2d.Dist(initPointBetweenFingers, prevPointBetweenFingers)
|
||||||
|
|
||||||
|
switch (pinchState) {
|
||||||
|
case 'not sure': {
|
||||||
|
if (touchDistance > 24) {
|
||||||
|
pinchState = 'zooming'
|
||||||
|
} else if (originDistance > 16) {
|
||||||
|
pinchState = 'panning'
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'panning': {
|
||||||
|
// Slightly more touch distance needed to go from panning to zooming
|
||||||
|
if (touchDistance > 64) {
|
||||||
|
pinchState = 'zooming'
|
||||||
|
}
|
||||||
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, 32)
|
}
|
||||||
|
|
||||||
const onPinch: PinchHandler = (gesture) => {
|
const onPinch: PinchHandler = (gesture) => {
|
||||||
const elm = ref.current
|
const elm = ref.current
|
||||||
|
@ -150,38 +207,36 @@ export function useGestureEvents(ref: React.RefObject<HTMLDivElement>) {
|
||||||
if (event instanceof WheelEvent) return
|
if (event instanceof WheelEvent) return
|
||||||
if (!(event.target === elm || elm?.contains(event.target as Node))) return
|
if (!(event.target === elm || elm?.contains(event.target as Node))) return
|
||||||
|
|
||||||
// Determine if the event is a gesture or a touch event.
|
// In (desktop) Safari, a two finger trackpad pinch will be a "gesturechange" event
|
||||||
// This affects how we calculate the touch distance.
|
// and will have 0 touches; on iOS, a two-finger pinch will be a "pointermove" event
|
||||||
// Because: When trackpad zooming on safari, a different unit is used.
|
// with two touches.
|
||||||
// By the way, Safari doesn't have TouchEvent...
|
const isSafariTrackpadPinch =
|
||||||
// ... so we have to manually check if the event is a TouchEvent.
|
gesture.type === 'gesturechange' || gesture.type === 'gestureend'
|
||||||
const isGesture = 'touches' in event ? false : true
|
|
||||||
|
|
||||||
// The distance between the two touch points
|
// The distance between the two touch points
|
||||||
currentTouchDistance = da[0]
|
currDistanceBetweenFingers = da[0]
|
||||||
|
|
||||||
// Only update the zoom if the pointers are far enough apart;
|
// Only update the zoom if the pointers are far enough apart;
|
||||||
// a very small touchDistance means that the user has probably
|
// a very small touchDistance means that the user has probably
|
||||||
// pinched out and their fingers are touching; this produces
|
// pinched out and their fingers are touching; this produces
|
||||||
// very unstable zooming behavior.
|
// very unstable zooming behavior.
|
||||||
if (isGesture || currentTouchDistance > 64) {
|
|
||||||
currentZoom = offset[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
const dx = origin[0] - prevOrigin.x
|
const dx = origin[0] - prevPointBetweenFingers.x
|
||||||
const dy = origin[1] - prevOrigin.y
|
const dy = origin[1] - prevPointBetweenFingers.y
|
||||||
|
|
||||||
prevOrigin.x = origin[0]
|
prevPointBetweenFingers.x = origin[0]
|
||||||
prevOrigin.y = origin[1]
|
prevPointBetweenFingers.y = origin[1]
|
||||||
|
|
||||||
updatePinchState(isGesture ? 'gesture' : 'touch')
|
updatePinchState(isSafariTrackpadPinch)
|
||||||
|
|
||||||
switch (pinchState) {
|
switch (pinchState) {
|
||||||
case 'zooming': {
|
case 'zooming': {
|
||||||
|
currZoom = offset[0]
|
||||||
|
|
||||||
editor.dispatch({
|
editor.dispatch({
|
||||||
type: 'pinch',
|
type: 'pinch',
|
||||||
name: 'pinch',
|
name: 'pinch',
|
||||||
point: { x: origin[0], y: origin[1], z: currentZoom },
|
point: { x: origin[0], y: origin[1], z: currZoom },
|
||||||
delta: { x: dx, y: dy },
|
delta: { x: dx, y: dy },
|
||||||
shiftKey: event.shiftKey,
|
shiftKey: event.shiftKey,
|
||||||
altKey: event.altKey,
|
altKey: event.altKey,
|
||||||
|
@ -213,7 +268,7 @@ export function useGestureEvents(ref: React.RefObject<HTMLDivElement>) {
|
||||||
|
|
||||||
const scale = offset[0]
|
const scale = offset[0]
|
||||||
|
|
||||||
pinchState = null
|
pinchState = 'not sure'
|
||||||
|
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
editor.dispatch({
|
editor.dispatch({
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue