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('skip')).toBeFalsy()
|
||||||
expect(w.emitted('label')).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
|
// Zone threshold: 7% of viewport width on each side
|
||||||
const ZONE_PCT = 0.07
|
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) {
|
function onPointerDown(e: PointerEvent) {
|
||||||
if (!motion.rich.value) return
|
if (!motion.rich.value) return
|
||||||
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
|
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
|
||||||
|
|
@ -76,6 +83,7 @@ function onPointerDown(e: PointerEvent) {
|
||||||
isHeld.value = true
|
isHeld.value = true
|
||||||
hoveredZone.value = null
|
hoveredZone.value = null
|
||||||
hoveredBucketName.value = null
|
hoveredBucketName.value = null
|
||||||
|
velocityBuf = []
|
||||||
emit('drag-start')
|
emit('drag-start')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -84,6 +92,13 @@ function onPointerMove(e: PointerEvent) {
|
||||||
deltaX.value = e.clientX - pickupX.value
|
deltaX.value = e.clientX - pickupX.value
|
||||||
deltaY.value = e.clientY - pickupY.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 vw = window.innerWidth
|
||||||
const zone: 'discard' | 'skip' | null =
|
const zone: 'discard' | 'skip' | null =
|
||||||
e.clientX < vw * ZONE_PCT ? 'discard' :
|
e.clientX < vw * ZONE_PCT ? 'discard' :
|
||||||
|
|
@ -113,6 +128,27 @@ function onPointerUp(e: PointerEvent) {
|
||||||
emit('zone-hover', null)
|
emit('zone-hover', null)
|
||||||
emit('bucket-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') {
|
if (hoveredZone.value === 'discard') {
|
||||||
hoveredZone.value = null
|
hoveredZone.value = null
|
||||||
hoveredBucketName.value = null
|
hoveredBucketName.value = null
|
||||||
|
|
@ -143,6 +179,7 @@ function onPointerCancel(e: PointerEvent) {
|
||||||
deltaY.value = 0
|
deltaY.value = 0
|
||||||
hoveredZone.value = null
|
hoveredZone.value = null
|
||||||
hoveredBucketName.value = null
|
hoveredBucketName.value = null
|
||||||
|
velocityBuf = []
|
||||||
emit('drag-end')
|
emit('drag-end')
|
||||||
emit('zone-hover', null)
|
emit('zone-hover', null)
|
||||||
emit('bucket-hover', null)
|
emit('bucket-hover', null)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue