feat(avocet): add velocity-based fling detection to toss gesture (option B: speed + alignment)
This commit is contained in:
parent
f8e911c48f
commit
1ccac024a4
2 changed files with 106 additions and 0 deletions
|
|
@ -111,4 +111,73 @@ describe('EmailCardStack', () => {
|
|||
expect(w.emitted('skip')).toBeFalsy()
|
||||
expect(w.emitted('label')).toBeFalsy()
|
||||
})
|
||||
|
||||
// Fling tests — mock performance.now() to control timestamps between events
|
||||
it('emits discard on fast leftward fling (option B: speed + alignment)', async () => {
|
||||
let mockTime = 0
|
||||
vi.spyOn(performance, 'now').mockImplementation(() => mockTime)
|
||||
const w = mount(EmailCardStack, { props: { item, isBucketMode: false } })
|
||||
const el = w.find('.card-wrapper').element
|
||||
mockPointerCapture(el)
|
||||
mockTime = 0; fire(el, 'pointerdown', { pointerId: 1, clientX: 512, clientY: 300 })
|
||||
mockTime = 30; fire(el, 'pointermove', { pointerId: 1, clientX: 400, clientY: 310 })
|
||||
mockTime = 50; fire(el, 'pointermove', { pointerId: 1, clientX: 288, clientY: 320 })
|
||||
// vx = (288-400)/(50-30)*1000 = -5600 px/s, vy ≈ 500 px/s
|
||||
// speed ≈ 5622 px/s > 600, alignment = 5600/5622 ≈ 0.996 > 0.707 ✓
|
||||
fire(el, 'pointerup', { pointerId: 1, clientX: 288, clientY: 320 })
|
||||
await w.vm.$nextTick()
|
||||
expect(w.emitted('discard')).toBeTruthy()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('emits skip on fast rightward fling', async () => {
|
||||
let mockTime = 0
|
||||
vi.spyOn(performance, 'now').mockImplementation(() => mockTime)
|
||||
const w = mount(EmailCardStack, { props: { item, isBucketMode: false } })
|
||||
const el = w.find('.card-wrapper').element
|
||||
mockPointerCapture(el)
|
||||
mockTime = 0; fire(el, 'pointerdown', { pointerId: 1, clientX: 512, clientY: 300 })
|
||||
mockTime = 30; fire(el, 'pointermove', { pointerId: 1, clientX: 624, clientY: 310 })
|
||||
mockTime = 50; fire(el, 'pointermove', { pointerId: 1, clientX: 736, clientY: 320 })
|
||||
// vx = (736-624)/(50-30)*1000 = 5600 px/s — mirror of discard case
|
||||
fire(el, 'pointerup', { pointerId: 1, clientX: 736, clientY: 320 })
|
||||
await w.vm.$nextTick()
|
||||
expect(w.emitted('skip')).toBeTruthy()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('does not fling on diagonal swipe (alignment < 0.707)', async () => {
|
||||
let mockTime = 0
|
||||
vi.spyOn(performance, 'now').mockImplementation(() => mockTime)
|
||||
const w = mount(EmailCardStack, { props: { item, isBucketMode: false } })
|
||||
const el = w.find('.card-wrapper').element
|
||||
mockPointerCapture(el)
|
||||
mockTime = 0; fire(el, 'pointerdown', { pointerId: 1, clientX: 512, clientY: 300 })
|
||||
mockTime = 30; fire(el, 'pointermove', { pointerId: 1, clientX: 400, clientY: 150 })
|
||||
mockTime = 50; fire(el, 'pointermove', { pointerId: 1, clientX: 288, clientY: 0 })
|
||||
// vx = -5600 px/s, vy = -7500 px/s, speed ≈ 9356 px/s
|
||||
// alignment = 5600/9356 ≈ 0.598 < 0.707 — too diagonal ✓
|
||||
fire(el, 'pointerup', { pointerId: 1, clientX: 288, clientY: 0 })
|
||||
await w.vm.$nextTick()
|
||||
expect(w.emitted('discard')).toBeFalsy()
|
||||
expect(w.emitted('skip')).toBeFalsy()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('does not fling on slow movement (speed < threshold)', async () => {
|
||||
let mockTime = 0
|
||||
vi.spyOn(performance, 'now').mockImplementation(() => mockTime)
|
||||
const w = mount(EmailCardStack, { props: { item, isBucketMode: false } })
|
||||
const el = w.find('.card-wrapper').element
|
||||
mockPointerCapture(el)
|
||||
mockTime = 0; fire(el, 'pointerdown', { pointerId: 1, clientX: 512, clientY: 300 })
|
||||
mockTime = 100; fire(el, 'pointermove', { pointerId: 1, clientX: 480, clientY: 300 })
|
||||
mockTime = 200; fire(el, 'pointermove', { pointerId: 1, clientX: 450, clientY: 300 })
|
||||
// vx = (450-480)/(200-100)*1000 = -300 px/s < 600 threshold
|
||||
fire(el, 'pointerup', { pointerId: 1, clientX: 450, clientY: 300 })
|
||||
await w.vm.$nextTick()
|
||||
expect(w.emitted('discard')).toBeFalsy()
|
||||
expect(w.emitted('skip')).toBeFalsy()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -66,6 +66,13 @@ const hoveredBucketName = ref<string | null>(null)
|
|||
// Zone threshold: 7% of viewport width on each side
|
||||
const ZONE_PCT = 0.07
|
||||
|
||||
// Fling detection — Option B: speed + direction alignment
|
||||
// Plain array (not ref) — drives no template state, updates on every pointermove
|
||||
const FLING_SPEED_PX_S = 600 // px/s minimum to qualify as a fling
|
||||
const FLING_ALIGN = 0.707 // cos(45°) — velocity must point within 45° of horizontal
|
||||
const FLING_WINDOW_MS = 50 // rolling sample window in ms
|
||||
let velocityBuf: { x: number; y: number; t: number }[] = []
|
||||
|
||||
function onPointerDown(e: PointerEvent) {
|
||||
if (!motion.rich.value) return
|
||||
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
|
||||
|
|
@ -76,6 +83,7 @@ function onPointerDown(e: PointerEvent) {
|
|||
isHeld.value = true
|
||||
hoveredZone.value = null
|
||||
hoveredBucketName.value = null
|
||||
velocityBuf = []
|
||||
emit('drag-start')
|
||||
}
|
||||
|
||||
|
|
@ -84,6 +92,13 @@ function onPointerMove(e: PointerEvent) {
|
|||
deltaX.value = e.clientX - pickupX.value
|
||||
deltaY.value = e.clientY - pickupY.value
|
||||
|
||||
// Rolling velocity buffer — keep only the last FLING_WINDOW_MS of samples
|
||||
const now = performance.now()
|
||||
velocityBuf.push({ x: e.clientX, y: e.clientY, t: now })
|
||||
while (velocityBuf.length > 1 && now - velocityBuf[0].t > FLING_WINDOW_MS) {
|
||||
velocityBuf.shift()
|
||||
}
|
||||
|
||||
const vw = window.innerWidth
|
||||
const zone: 'discard' | 'skip' | null =
|
||||
e.clientX < vw * ZONE_PCT ? 'discard' :
|
||||
|
|
@ -113,6 +128,27 @@ function onPointerUp(e: PointerEvent) {
|
|||
emit('zone-hover', null)
|
||||
emit('bucket-hover', null)
|
||||
|
||||
// Fling detection (Option B): fires before zone check so a fast fling
|
||||
// resolves even if the pointer didn't reach the 7% edge zone
|
||||
if (hoveredZone.value === null && hoveredBucketName.value === null
|
||||
&& velocityBuf.length >= 2) {
|
||||
const oldest = velocityBuf[0]
|
||||
const newest = velocityBuf[velocityBuf.length - 1]
|
||||
const dt = (newest.t - oldest.t) / 1000 // seconds
|
||||
if (dt > 0) {
|
||||
const vx = (newest.x - oldest.x) / dt
|
||||
const vy = (newest.y - oldest.y) / dt
|
||||
const speed = Math.sqrt(vx * vx + vy * vy)
|
||||
// Require: fast enough AND velocity points within 45° of horizontal
|
||||
if (speed >= FLING_SPEED_PX_S && Math.abs(vx) / speed >= FLING_ALIGN) {
|
||||
velocityBuf = []
|
||||
emit(vx < 0 ? 'discard' : 'skip')
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
velocityBuf = []
|
||||
|
||||
if (hoveredZone.value === 'discard') {
|
||||
hoveredZone.value = null
|
||||
hoveredBucketName.value = null
|
||||
|
|
@ -143,6 +179,7 @@ function onPointerCancel(e: PointerEvent) {
|
|||
deltaY.value = 0
|
||||
hoveredZone.value = null
|
||||
hoveredBucketName.value = null
|
||||
velocityBuf = []
|
||||
emit('drag-end')
|
||||
emit('zone-hover', null)
|
||||
emit('bucket-hover', null)
|
||||
|
|
|
|||
Loading…
Reference in a new issue