fix(stage-ui): use touch events for mobile swipe (#2508)
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
/** Input source that can drive Swipeable. */
|
||||
export type SwipeableInput = 'pointer' | 'wheel'
|
||||
export type SwipeableInput = 'touch' | 'wheel'
|
||||
|
||||
/** Horizontal direction that selects the action. */
|
||||
export type SwipeableDirection = 'left' | 'right'
|
||||
@@ -8,11 +8,11 @@ export type SwipeableDirection = 'left' | 'right'
|
||||
export interface SwipeableProps {
|
||||
/** Enables gesture recognition. @default true */
|
||||
enabled?: boolean
|
||||
/** Selects touch/pointer dragging or desktop horizontal-wheel input. @default 'pointer' */
|
||||
/** Selects touch dragging or desktop horizontal-wheel input. @default 'touch' */
|
||||
input?: SwipeableInput
|
||||
/** Selects the horizontal direction that commits the action. @default 'left' */
|
||||
direction?: SwipeableDirection
|
||||
/** Ignores pointer or wheel jitter below this distance, in pixels. @default 8 */
|
||||
/** Ignores touch or wheel jitter below this distance, in pixels. @default 8 */
|
||||
startDistance?: number
|
||||
/** Commits the action when the directed distance reaches this value, in pixels. @default 48 */
|
||||
threshold?: number
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import type { SwipeableProps, SwipeableSlotProps } from './swipeable'
|
||||
|
||||
import { useEventListener, usePreferredReducedMotion } from '@vueuse/core'
|
||||
import { usePreferredReducedMotion, useSwipe } from '@vueuse/core'
|
||||
import { animate } from 'animejs'
|
||||
import { clamp } from 'es-toolkit'
|
||||
import { computed, onUnmounted, reactive, shallowRef, useTemplateRef, watch } from 'vue'
|
||||
@@ -11,7 +11,7 @@ import { useSwipeGesture } from './use-swipe-gesture'
|
||||
const props = withDefaults(defineProps<SwipeableProps>(), {
|
||||
direction: 'left',
|
||||
enabled: true,
|
||||
input: 'pointer',
|
||||
input: 'touch',
|
||||
startDistance: 8,
|
||||
threshold: 48,
|
||||
})
|
||||
@@ -32,6 +32,15 @@ const { state: wheelGesture } = useSwipeGesture(rootRef, {
|
||||
&& !event.ctrlKey
|
||||
&& event.deltaMode === WheelEvent.DOM_DELTA_PIXEL,
|
||||
})
|
||||
const {
|
||||
lengthX: touchDistanceX,
|
||||
lengthY: touchDistanceY,
|
||||
} = useSwipe(rootRef, {
|
||||
threshold: 0,
|
||||
onSwipeStart: beginTouchSwipe,
|
||||
onSwipe: updateTouchSwipe,
|
||||
onSwipeEnd: finishTouchSwipe,
|
||||
})
|
||||
const position = reactive({ x: 0 })
|
||||
const active = shallowRef(false)
|
||||
const thresholdCrossed = shallowRef(false)
|
||||
@@ -45,9 +54,6 @@ const slotProps = computed<SwipeableSlotProps>(() => ({
|
||||
}))
|
||||
|
||||
let returnAnimation: ReturnType<typeof animate> | undefined
|
||||
let activePointerId: number | undefined
|
||||
let pointerStartX = 0
|
||||
let pointerStartY = 0
|
||||
let wheelDistance = 0
|
||||
let wheelIntent: 'pending' | 'horizontal' | 'vertical' = 'pending'
|
||||
let wheelSessionActive = false
|
||||
@@ -116,25 +122,19 @@ function resetPosition() {
|
||||
animatePositionToRest()
|
||||
}
|
||||
|
||||
function beginPointerSwipe(event: PointerEvent) {
|
||||
if (!props.enabled || props.input !== 'pointer')
|
||||
function beginTouchSwipe() {
|
||||
if (!props.enabled || props.input !== 'touch')
|
||||
return
|
||||
|
||||
if (!event.isPrimary || (event.pointerType === 'mouse' && event.button !== 0))
|
||||
return
|
||||
|
||||
activePointerId = event.pointerId
|
||||
pointerStartX = event.clientX
|
||||
pointerStartY = event.clientY
|
||||
returnAnimation?.cancel()
|
||||
}
|
||||
|
||||
function updatePointerSwipe(event: PointerEvent) {
|
||||
if (event.pointerId !== activePointerId)
|
||||
function updateTouchSwipe() {
|
||||
if (!props.enabled || props.input !== 'touch')
|
||||
return
|
||||
|
||||
const deltaX = pointerStartX - event.clientX
|
||||
const deltaY = Math.abs(event.clientY - pointerStartY)
|
||||
const deltaX = touchDistanceX.value
|
||||
const deltaY = Math.abs(touchDistanceY.value)
|
||||
const distance = directedDistance(deltaX)
|
||||
if (Math.max(Math.abs(deltaX), deltaY) < props.startDistance)
|
||||
return
|
||||
@@ -144,32 +144,19 @@ function updatePointerSwipe(event: PointerEvent) {
|
||||
return
|
||||
}
|
||||
|
||||
// The nested action menu must receive the first move before this ancestor
|
||||
// captures the pointer. Reka uses that move to cancel its long-press timer.
|
||||
if (!active.value && event.isTrusted)
|
||||
rootRef.value?.setPointerCapture(event.pointerId)
|
||||
|
||||
returnAnimation?.cancel()
|
||||
active.value = true
|
||||
setGestureDistance(distance)
|
||||
}
|
||||
|
||||
function finishPointerSwipe(event: PointerEvent) {
|
||||
if (event.pointerId !== activePointerId)
|
||||
function finishTouchSwipe(event: TouchEvent) {
|
||||
if (props.input !== 'touch')
|
||||
return
|
||||
|
||||
activePointerId = undefined
|
||||
if (props.enabled && thresholdCrossed.value)
|
||||
const shouldCommit = event.type === 'touchend' && props.enabled && thresholdCrossed.value
|
||||
resetPosition()
|
||||
if (shouldCommit)
|
||||
emit('commit')
|
||||
resetPosition()
|
||||
}
|
||||
|
||||
function cancelPointerSwipe(event: PointerEvent) {
|
||||
if (event.pointerId !== activePointerId)
|
||||
return
|
||||
|
||||
activePointerId = undefined
|
||||
resetPosition()
|
||||
}
|
||||
|
||||
function finishWheelSwipe() {
|
||||
@@ -188,7 +175,6 @@ function finishWheelSwipe() {
|
||||
}
|
||||
|
||||
function cancelGesture() {
|
||||
activePointerId = undefined
|
||||
wheelIntent = 'pending'
|
||||
wheelSessionActive = false
|
||||
wheelDistance = 0
|
||||
@@ -259,10 +245,6 @@ function updateWheelSwipe(state: NonNullable<typeof wheelGesture.value>) {
|
||||
setGestureDistance(wheelDistance)
|
||||
}
|
||||
|
||||
useEventListener(rootRef, 'pointerdown', beginPointerSwipe, { passive: true })
|
||||
useEventListener(rootRef, 'pointermove', updatePointerSwipe, { passive: true })
|
||||
useEventListener(rootRef, 'pointerup', finishPointerSwipe, { passive: true })
|
||||
useEventListener(rootRef, ['pointercancel', 'lostpointercapture'], cancelPointerSwipe, { passive: true })
|
||||
watch(wheelGesture, (state) => {
|
||||
if (state)
|
||||
updateWheelSwipe(state)
|
||||
@@ -283,7 +265,7 @@ onUnmounted(() => {
|
||||
data-swipeable
|
||||
:data-swipe-active="active"
|
||||
:style="{
|
||||
touchAction: enabled && input === 'pointer' ? 'pan-y' : undefined,
|
||||
touchAction: enabled && input === 'touch' ? 'pan-y' : undefined,
|
||||
}"
|
||||
:class="['relative']"
|
||||
>
|
||||
|
||||
+1
-1
@@ -55,7 +55,7 @@ function getReplyIconStyle(swipe: SwipeableSlotProps) {
|
||||
<Swipeable
|
||||
v-slot="swipe"
|
||||
:enabled="replyEnabled"
|
||||
:input="variant === 'mobile' ? 'pointer' : 'wheel'"
|
||||
:input="variant === 'mobile' ? 'touch' : 'wheel'"
|
||||
@commit="emit('reply')"
|
||||
@threshold-enter="triggerHaptic('medium')"
|
||||
>
|
||||
|
||||
+93
-30
@@ -98,6 +98,24 @@ function dispatchTouchPointer(element: EventTarget, type: 'pointerdown' | 'point
|
||||
}))
|
||||
}
|
||||
|
||||
function dispatchTouchEvent(element: HTMLElement, type: 'touchstart' | 'touchmove' | 'touchend' | 'touchcancel', clientX: number) {
|
||||
const touch = new Touch({
|
||||
clientX,
|
||||
clientY: 60,
|
||||
identifier: 1,
|
||||
target: element,
|
||||
})
|
||||
const activeTouches = type === 'touchend' || type === 'touchcancel' ? [] : [touch]
|
||||
|
||||
element.dispatchEvent(new TouchEvent(type, {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
changedTouches: [touch],
|
||||
targetTouches: activeTouches,
|
||||
touches: activeTouches,
|
||||
}))
|
||||
}
|
||||
|
||||
describe('chat history', () => {
|
||||
it('renders a stored reply relation inside the message bubble', async () => {
|
||||
const screen = await render(ChatHistory, {
|
||||
@@ -1074,10 +1092,14 @@ describe('chat history', () => {
|
||||
if (!swipeSurface)
|
||||
throw new Error('Expected a mobile message swipe surface.')
|
||||
|
||||
dispatchPointerSwipe(swipeSurface, 40, 100, 'touch')
|
||||
dispatchTouchEvent(swipeSurface, 'touchstart', 40)
|
||||
dispatchTouchEvent(swipeSurface, 'touchmove', 100)
|
||||
dispatchTouchEvent(swipeSurface, 'touchend', 100)
|
||||
expect(screen.emitted('replyMessage')).toBeUndefined()
|
||||
|
||||
dispatchPointerSwipe(swipeSurface, 100, 40, 'touch')
|
||||
dispatchTouchEvent(swipeSurface, 'touchstart', 100)
|
||||
dispatchTouchEvent(swipeSurface, 'touchmove', 40)
|
||||
dispatchTouchEvent(swipeSurface, 'touchend', 40)
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.emitted('replyMessage')).toEqual([[
|
||||
@@ -1089,7 +1111,72 @@ describe('chat history', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('returns a message to rest when the pointer gesture is cancelled', async () => {
|
||||
// https://github.com/moeru-ai/airi/pull/2489
|
||||
// ROOT CAUSE:
|
||||
//
|
||||
// Mobile Safari can dispatch lostpointercapture after a swipe surface captures
|
||||
// the active touch pointer. The pointer handler treated this event as a hard
|
||||
// cancellation and ignored all later movement from the same physical touch.
|
||||
//
|
||||
// The mobile gesture now follows the Touch Events stream. Pointer capture loss
|
||||
// does not terminate that stream, and touchcancel remains the cancellation signal.
|
||||
it('continues a mobile swipe after pointer capture is lost as reported in PR #2489', async () => {
|
||||
const message: ChatHistoryItem = {
|
||||
id: 'lost-pointer-capture-target',
|
||||
role: 'user',
|
||||
content: 'Continue this swipe',
|
||||
}
|
||||
const screen = await render(ChatHistory, {
|
||||
props: {
|
||||
messages: [message],
|
||||
variant: 'mobile',
|
||||
style: 'height: 240px; width: 320px; overflow-y: auto;',
|
||||
},
|
||||
global: {
|
||||
plugins: [createEnglishI18n()],
|
||||
},
|
||||
})
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.container.querySelector('[data-swipeable-surface]')).not.toBeNull()
|
||||
})
|
||||
const swipeSurface = screen.container.querySelector<HTMLElement>('[data-swipeable-surface]')
|
||||
if (!swipeSurface)
|
||||
throw new Error('Expected a mobile message swipe surface.')
|
||||
|
||||
dispatchTouchPointer(swipeSurface, 'pointerdown', 100)
|
||||
dispatchTouchEvent(swipeSurface, 'touchstart', 100)
|
||||
dispatchTouchPointer(swipeSurface, 'pointermove', 84)
|
||||
dispatchTouchEvent(swipeSurface, 'touchmove', 84)
|
||||
swipeSurface.dispatchEvent(new PointerEvent('lostpointercapture', {
|
||||
bubbles: true,
|
||||
pointerId: 1,
|
||||
pointerType: 'touch',
|
||||
}))
|
||||
dispatchTouchPointer(swipeSurface, 'pointermove', 40)
|
||||
dispatchTouchEvent(swipeSurface, 'touchmove', 40)
|
||||
swipeSurface.dispatchEvent(new PointerEvent('pointerup', {
|
||||
bubbles: true,
|
||||
buttons: 0,
|
||||
clientX: 40,
|
||||
clientY: 60,
|
||||
isPrimary: true,
|
||||
pointerId: 1,
|
||||
pointerType: 'touch',
|
||||
}))
|
||||
dispatchTouchEvent(swipeSurface, 'touchend', 40)
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.emitted('replyMessage')).toEqual([[
|
||||
{
|
||||
message,
|
||||
label: 'You',
|
||||
},
|
||||
]])
|
||||
})
|
||||
})
|
||||
|
||||
it('returns a message to rest when the touch gesture is cancelled', async () => {
|
||||
const message: ChatHistoryItem = { id: 'cancel-target', role: 'user', content: 'Cancel swipe' }
|
||||
const screen = await render(ChatHistory, {
|
||||
props: {
|
||||
@@ -1109,37 +1196,13 @@ describe('chat history', () => {
|
||||
if (!swipeSurface)
|
||||
throw new Error('Expected a message swipe surface.')
|
||||
|
||||
swipeSurface.dispatchEvent(new PointerEvent('pointerdown', {
|
||||
bubbles: true,
|
||||
buttons: 1,
|
||||
clientX: 100,
|
||||
clientY: 60,
|
||||
isPrimary: true,
|
||||
pointerId: 1,
|
||||
pointerType: 'mouse',
|
||||
}))
|
||||
swipeSurface.dispatchEvent(new PointerEvent('pointermove', {
|
||||
bubbles: true,
|
||||
buttons: 1,
|
||||
clientX: 40,
|
||||
clientY: 62,
|
||||
isPrimary: true,
|
||||
pointerId: 1,
|
||||
pointerType: 'mouse',
|
||||
}))
|
||||
dispatchTouchEvent(swipeSurface, 'touchstart', 100)
|
||||
dispatchTouchEvent(swipeSurface, 'touchmove', 40)
|
||||
await vi.waitFor(() => {
|
||||
expect(swipeSurface.dataset.swipeActive).toBe('true')
|
||||
})
|
||||
|
||||
swipeSurface.dispatchEvent(new PointerEvent('pointercancel', {
|
||||
bubbles: true,
|
||||
buttons: 0,
|
||||
clientX: 40,
|
||||
clientY: 62,
|
||||
isPrimary: true,
|
||||
pointerId: 1,
|
||||
pointerType: 'mouse',
|
||||
}))
|
||||
dispatchTouchEvent(swipeSurface, 'touchcancel', 40)
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(swipeSurface.dataset.swipeActive).toBe('false')
|
||||
|
||||
Reference in New Issue
Block a user