Pan capturing (#10304)

Panning or zooming "captures" wheel events. While events are captured, further
events of the same type will continue the pan/zoom action. The capture expires
if no events are received for 250ms. A trackpad capture also expires if any
pointer movement occurs.
This commit is contained in:
Kaz Wesley 2024-06-19 09:49:38 -07:00 committed by GitHub
parent ccfeac1c02
commit 3f70307a88
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 107 additions and 18 deletions

View File

@ -662,6 +662,8 @@ const groupColors = computed(() => {
@click="handleClick"
@dragover.prevent
@drop.prevent="handleFileDrop($event)"
@pointermove.capture="graphNavigator.pointerEventsCapture.pointermove"
@wheel.capture="graphNavigator.pointerEventsCapture.wheel"
>
<div class="layer" :style="{ transform: graphNavigator.transform }">
<GraphNodes

View File

@ -1,5 +1,6 @@
/** @file Vue composables for listening to DOM events. */
import type { KeyboardComposable } from '@/composables/keyboard.ts'
import type { Opt } from '@/util/data/opt'
import { Vec2 } from '@/util/data/vec2'
import { type VueInstance } from '@vueuse/core'
@ -528,3 +529,85 @@ export function useArrows(
return { events, moving }
}
/** Supports panning or zooming "capturing" wheel events.
*
* While events are captured, further events of the same type will continue the pan/zoom action.
* The capture expires if no events are received within the specified `captureDurationMs`.
* A trackpad capture also expires if any pointer movement occurs.
*/
export function useWheelActions(
keyboard: KeyboardComposable,
captureDurationMs: number,
onZoom: (e: WheelEvent, inputType: 'trackpad' | 'wheel') => boolean | void,
onPan: (e: WheelEvent) => boolean | void,
) {
let prevEventPanInfo:
| ({ expiration: number } & (
| { type: 'trackpad-zoom' }
| { type: 'wheel-zoom' }
| { type: 'pan'; trackpad: boolean }
))
| undefined = undefined
type WheelEventType = 'trackpad-zoom' | 'wheel-zoom' | 'pan'
function classifyEvent(e: WheelEvent): WheelEventType {
if (e.ctrlKey) {
// A pinch gesture is represented by setting `e.ctrlKey`. It can be distinguished from an actual Ctrl+wheel
// combination because the real Ctrl key emits keyup/keydown events.
const isGesture = !keyboard.ctrl
return isGesture ? 'trackpad-zoom' : 'wheel-zoom'
} else {
return 'pan'
}
}
function handleWheel(e: WheelEvent) {
const newType = classifyEvent(e)
if (e.eventPhase === e.CAPTURING_PHASE) {
if (newType !== prevEventPanInfo?.type || e.timeStamp > prevEventPanInfo.expiration) {
prevEventPanInfo = undefined
return
}
}
const expiration = e.timeStamp + captureDurationMs
if (newType === 'wheel-zoom') {
prevEventPanInfo = { expiration, type: newType }
onZoom(e, 'wheel')
} else if (newType === 'trackpad-zoom') {
prevEventPanInfo = { expiration, type: newType }
onZoom(e, 'trackpad')
} else if (newType === 'pan') {
const alreadyKnownTrackpad = prevEventPanInfo?.type === 'pan' && prevEventPanInfo.trackpad
prevEventPanInfo = {
expiration,
type: newType,
// Heuristic: Trackpad panning is usually multi-axis; wheel panning is not.
trackpad: alreadyKnownTrackpad || (e.deltaX !== 0 && e.deltaY !== 0),
}
onPan(e)
}
e.preventDefault()
e.stopPropagation()
}
function pointermove() {
// If a `pointermove` event occurs, any trackpad action has ended.
if (
prevEventPanInfo?.type === 'trackpad-zoom' ||
(prevEventPanInfo?.type === 'pan' && prevEventPanInfo.trackpad)
) {
prevEventPanInfo = undefined
}
}
return {
events: {
wheel: handleWheel,
},
captureEvents: {
pointermove,
wheel: handleWheel,
},
}
}

View File

@ -7,6 +7,7 @@ import {
useEvent,
usePointer,
useResizeObserver,
useWheelActions,
} from '@/composables/events'
import type { KeyboardComposable } from '@/composables/keyboard'
import { Rect } from '@/util/data/rect'
@ -24,6 +25,7 @@ const ZOOM_LEVELS_REVERSED = [...ZOOM_LEVELS].reverse()
* If we are that close to next zoom level, we should choose the next one instead
* to avoid small unnoticeable changes to zoom. */
const ZOOM_SKIP_THRESHOLD = 0.05
const WHEEL_CAPTURE_DURATION_MS = 250
function elemRect(target: Element | undefined): Rect {
if (target != null && target instanceof Element)
@ -275,6 +277,24 @@ export function useNavigator(
scale.skip()
}
const { events: wheelEvents, captureEvents: wheelEventsCapture } = useWheelActions(
keyboard,
WHEEL_CAPTURE_DURATION_MS,
(e, inputType) => {
if (inputType === 'trackpad') {
// OS X trackpad events provide usable rate-of-change information.
updateScale((oldValue: number) => oldValue * Math.exp(-e.deltaY / 100))
} else {
// Mouse wheel rate information is unreliable. We just step in the direction of the sign.
stepZoom(-Math.sign(e.deltaY))
}
},
(e) => {
const delta = new Vec2(e.deltaX, e.deltaY)
scrollTo(center.value.addScaled(delta, 1 / scale.value))
},
)
return proxyRefs({
pointerEvents: {
dragover(e: DragEvent) {
@ -301,28 +321,12 @@ export function useNavigator(
panPointer.events.pointerdown(e)
zoomPointer.events.pointerdown(e)
},
wheel(e: WheelEvent) {
e.preventDefault()
if (e.ctrlKey) {
// A pinch gesture is represented by setting `e.ctrlKey`. It can be distinguished from an actual Ctrl+wheel
// combination because the real Ctrl key emits keyup/keydown events.
const isGesture = !keyboard.ctrl
if (isGesture) {
// OS X trackpad events provide usable rate-of-change information.
updateScale((oldValue: number) => oldValue * Math.exp(-e.deltaY / 100))
} else {
// Mouse wheel rate information is unreliable. We just step in the direction of the sign.
stepZoom(-Math.sign(e.deltaY))
}
} else {
const delta = new Vec2(e.deltaX, e.deltaY)
scrollTo(center.value.addScaled(delta, 1 / scale.value))
}
},
contextmenu(e: Event) {
e.preventDefault()
},
wheel: wheelEvents.wheel,
},
pointerEventsCapture: wheelEventsCapture,
keyboardEvents: panArrows.events,
translate,
targetCenter: readonly(targetCenter),