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" @click="handleClick"
@dragover.prevent @dragover.prevent
@drop.prevent="handleFileDrop($event)" @drop.prevent="handleFileDrop($event)"
@pointermove.capture="graphNavigator.pointerEventsCapture.pointermove"
@wheel.capture="graphNavigator.pointerEventsCapture.wheel"
> >
<div class="layer" :style="{ transform: graphNavigator.transform }"> <div class="layer" :style="{ transform: graphNavigator.transform }">
<GraphNodes <GraphNodes

View File

@ -1,5 +1,6 @@
/** @file Vue composables for listening to DOM events. */ /** @file Vue composables for listening to DOM events. */
import type { KeyboardComposable } from '@/composables/keyboard.ts'
import type { Opt } from '@/util/data/opt' import type { Opt } from '@/util/data/opt'
import { Vec2 } from '@/util/data/vec2' import { Vec2 } from '@/util/data/vec2'
import { type VueInstance } from '@vueuse/core' import { type VueInstance } from '@vueuse/core'
@ -528,3 +529,85 @@ export function useArrows(
return { events, moving } 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, useEvent,
usePointer, usePointer,
useResizeObserver, useResizeObserver,
useWheelActions,
} from '@/composables/events' } from '@/composables/events'
import type { KeyboardComposable } from '@/composables/keyboard' import type { KeyboardComposable } from '@/composables/keyboard'
import { Rect } from '@/util/data/rect' 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 * If we are that close to next zoom level, we should choose the next one instead
* to avoid small unnoticeable changes to zoom. */ * to avoid small unnoticeable changes to zoom. */
const ZOOM_SKIP_THRESHOLD = 0.05 const ZOOM_SKIP_THRESHOLD = 0.05
const WHEEL_CAPTURE_DURATION_MS = 250
function elemRect(target: Element | undefined): Rect { function elemRect(target: Element | undefined): Rect {
if (target != null && target instanceof Element) if (target != null && target instanceof Element)
@ -275,6 +277,24 @@ export function useNavigator(
scale.skip() 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({ return proxyRefs({
pointerEvents: { pointerEvents: {
dragover(e: DragEvent) { dragover(e: DragEvent) {
@ -301,28 +321,12 @@ export function useNavigator(
panPointer.events.pointerdown(e) panPointer.events.pointerdown(e)
zoomPointer.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) { contextmenu(e: Event) {
e.preventDefault() e.preventDefault()
}, },
wheel: wheelEvents.wheel,
}, },
pointerEventsCapture: wheelEventsCapture,
keyboardEvents: panArrows.events, keyboardEvents: panArrows.events,
translate, translate,
targetCenter: readonly(targetCenter), targetCenter: readonly(targetCenter),