fix(stage-ui): use touch events for mobile swipe (#2508)

This commit is contained in:
Neko
2026-09-10 16:11:09 +08:00
committed by GitHub
parent 13ae708541
commit 21d0e9d3a7
4 changed files with 120 additions and 75 deletions
@@ -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']"
>
@@ -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')"
>
@@ -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')