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. */
|
/** Input source that can drive Swipeable. */
|
||||||
export type SwipeableInput = 'pointer' | 'wheel'
|
export type SwipeableInput = 'touch' | 'wheel'
|
||||||
|
|
||||||
/** Horizontal direction that selects the action. */
|
/** Horizontal direction that selects the action. */
|
||||||
export type SwipeableDirection = 'left' | 'right'
|
export type SwipeableDirection = 'left' | 'right'
|
||||||
@@ -8,11 +8,11 @@ export type SwipeableDirection = 'left' | 'right'
|
|||||||
export interface SwipeableProps {
|
export interface SwipeableProps {
|
||||||
/** Enables gesture recognition. @default true */
|
/** Enables gesture recognition. @default true */
|
||||||
enabled?: boolean
|
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
|
input?: SwipeableInput
|
||||||
/** Selects the horizontal direction that commits the action. @default 'left' */
|
/** Selects the horizontal direction that commits the action. @default 'left' */
|
||||||
direction?: SwipeableDirection
|
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
|
startDistance?: number
|
||||||
/** Commits the action when the directed distance reaches this value, in pixels. @default 48 */
|
/** Commits the action when the directed distance reaches this value, in pixels. @default 48 */
|
||||||
threshold?: number
|
threshold?: number
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { SwipeableProps, SwipeableSlotProps } from './swipeable'
|
import type { SwipeableProps, SwipeableSlotProps } from './swipeable'
|
||||||
|
|
||||||
import { useEventListener, usePreferredReducedMotion } from '@vueuse/core'
|
import { usePreferredReducedMotion, useSwipe } from '@vueuse/core'
|
||||||
import { animate } from 'animejs'
|
import { animate } from 'animejs'
|
||||||
import { clamp } from 'es-toolkit'
|
import { clamp } from 'es-toolkit'
|
||||||
import { computed, onUnmounted, reactive, shallowRef, useTemplateRef, watch } from 'vue'
|
import { computed, onUnmounted, reactive, shallowRef, useTemplateRef, watch } from 'vue'
|
||||||
@@ -11,7 +11,7 @@ import { useSwipeGesture } from './use-swipe-gesture'
|
|||||||
const props = withDefaults(defineProps<SwipeableProps>(), {
|
const props = withDefaults(defineProps<SwipeableProps>(), {
|
||||||
direction: 'left',
|
direction: 'left',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
input: 'pointer',
|
input: 'touch',
|
||||||
startDistance: 8,
|
startDistance: 8,
|
||||||
threshold: 48,
|
threshold: 48,
|
||||||
})
|
})
|
||||||
@@ -32,6 +32,15 @@ const { state: wheelGesture } = useSwipeGesture(rootRef, {
|
|||||||
&& !event.ctrlKey
|
&& !event.ctrlKey
|
||||||
&& event.deltaMode === WheelEvent.DOM_DELTA_PIXEL,
|
&& 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 position = reactive({ x: 0 })
|
||||||
const active = shallowRef(false)
|
const active = shallowRef(false)
|
||||||
const thresholdCrossed = shallowRef(false)
|
const thresholdCrossed = shallowRef(false)
|
||||||
@@ -45,9 +54,6 @@ const slotProps = computed<SwipeableSlotProps>(() => ({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
let returnAnimation: ReturnType<typeof animate> | undefined
|
let returnAnimation: ReturnType<typeof animate> | undefined
|
||||||
let activePointerId: number | undefined
|
|
||||||
let pointerStartX = 0
|
|
||||||
let pointerStartY = 0
|
|
||||||
let wheelDistance = 0
|
let wheelDistance = 0
|
||||||
let wheelIntent: 'pending' | 'horizontal' | 'vertical' = 'pending'
|
let wheelIntent: 'pending' | 'horizontal' | 'vertical' = 'pending'
|
||||||
let wheelSessionActive = false
|
let wheelSessionActive = false
|
||||||
@@ -116,25 +122,19 @@ function resetPosition() {
|
|||||||
animatePositionToRest()
|
animatePositionToRest()
|
||||||
}
|
}
|
||||||
|
|
||||||
function beginPointerSwipe(event: PointerEvent) {
|
function beginTouchSwipe() {
|
||||||
if (!props.enabled || props.input !== 'pointer')
|
if (!props.enabled || props.input !== 'touch')
|
||||||
return
|
return
|
||||||
|
|
||||||
if (!event.isPrimary || (event.pointerType === 'mouse' && event.button !== 0))
|
|
||||||
return
|
|
||||||
|
|
||||||
activePointerId = event.pointerId
|
|
||||||
pointerStartX = event.clientX
|
|
||||||
pointerStartY = event.clientY
|
|
||||||
returnAnimation?.cancel()
|
returnAnimation?.cancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
function updatePointerSwipe(event: PointerEvent) {
|
function updateTouchSwipe() {
|
||||||
if (event.pointerId !== activePointerId)
|
if (!props.enabled || props.input !== 'touch')
|
||||||
return
|
return
|
||||||
|
|
||||||
const deltaX = pointerStartX - event.clientX
|
const deltaX = touchDistanceX.value
|
||||||
const deltaY = Math.abs(event.clientY - pointerStartY)
|
const deltaY = Math.abs(touchDistanceY.value)
|
||||||
const distance = directedDistance(deltaX)
|
const distance = directedDistance(deltaX)
|
||||||
if (Math.max(Math.abs(deltaX), deltaY) < props.startDistance)
|
if (Math.max(Math.abs(deltaX), deltaY) < props.startDistance)
|
||||||
return
|
return
|
||||||
@@ -144,32 +144,19 @@ function updatePointerSwipe(event: PointerEvent) {
|
|||||||
return
|
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()
|
returnAnimation?.cancel()
|
||||||
active.value = true
|
active.value = true
|
||||||
setGestureDistance(distance)
|
setGestureDistance(distance)
|
||||||
}
|
}
|
||||||
|
|
||||||
function finishPointerSwipe(event: PointerEvent) {
|
function finishTouchSwipe(event: TouchEvent) {
|
||||||
if (event.pointerId !== activePointerId)
|
if (props.input !== 'touch')
|
||||||
return
|
return
|
||||||
|
|
||||||
activePointerId = undefined
|
const shouldCommit = event.type === 'touchend' && props.enabled && thresholdCrossed.value
|
||||||
if (props.enabled && thresholdCrossed.value)
|
resetPosition()
|
||||||
|
if (shouldCommit)
|
||||||
emit('commit')
|
emit('commit')
|
||||||
resetPosition()
|
|
||||||
}
|
|
||||||
|
|
||||||
function cancelPointerSwipe(event: PointerEvent) {
|
|
||||||
if (event.pointerId !== activePointerId)
|
|
||||||
return
|
|
||||||
|
|
||||||
activePointerId = undefined
|
|
||||||
resetPosition()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function finishWheelSwipe() {
|
function finishWheelSwipe() {
|
||||||
@@ -188,7 +175,6 @@ function finishWheelSwipe() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function cancelGesture() {
|
function cancelGesture() {
|
||||||
activePointerId = undefined
|
|
||||||
wheelIntent = 'pending'
|
wheelIntent = 'pending'
|
||||||
wheelSessionActive = false
|
wheelSessionActive = false
|
||||||
wheelDistance = 0
|
wheelDistance = 0
|
||||||
@@ -259,10 +245,6 @@ function updateWheelSwipe(state: NonNullable<typeof wheelGesture.value>) {
|
|||||||
setGestureDistance(wheelDistance)
|
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) => {
|
watch(wheelGesture, (state) => {
|
||||||
if (state)
|
if (state)
|
||||||
updateWheelSwipe(state)
|
updateWheelSwipe(state)
|
||||||
@@ -283,7 +265,7 @@ onUnmounted(() => {
|
|||||||
data-swipeable
|
data-swipeable
|
||||||
:data-swipe-active="active"
|
:data-swipe-active="active"
|
||||||
:style="{
|
:style="{
|
||||||
touchAction: enabled && input === 'pointer' ? 'pan-y' : undefined,
|
touchAction: enabled && input === 'touch' ? 'pan-y' : undefined,
|
||||||
}"
|
}"
|
||||||
:class="['relative']"
|
:class="['relative']"
|
||||||
>
|
>
|
||||||
|
|||||||
+1
-1
@@ -55,7 +55,7 @@ function getReplyIconStyle(swipe: SwipeableSlotProps) {
|
|||||||
<Swipeable
|
<Swipeable
|
||||||
v-slot="swipe"
|
v-slot="swipe"
|
||||||
:enabled="replyEnabled"
|
:enabled="replyEnabled"
|
||||||
:input="variant === 'mobile' ? 'pointer' : 'wheel'"
|
:input="variant === 'mobile' ? 'touch' : 'wheel'"
|
||||||
@commit="emit('reply')"
|
@commit="emit('reply')"
|
||||||
@threshold-enter="triggerHaptic('medium')"
|
@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', () => {
|
describe('chat history', () => {
|
||||||
it('renders a stored reply relation inside the message bubble', async () => {
|
it('renders a stored reply relation inside the message bubble', async () => {
|
||||||
const screen = await render(ChatHistory, {
|
const screen = await render(ChatHistory, {
|
||||||
@@ -1074,10 +1092,14 @@ describe('chat history', () => {
|
|||||||
if (!swipeSurface)
|
if (!swipeSurface)
|
||||||
throw new Error('Expected a mobile message swipe surface.')
|
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()
|
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(() => {
|
await vi.waitFor(() => {
|
||||||
expect(screen.emitted('replyMessage')).toEqual([[
|
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 message: ChatHistoryItem = { id: 'cancel-target', role: 'user', content: 'Cancel swipe' }
|
||||||
const screen = await render(ChatHistory, {
|
const screen = await render(ChatHistory, {
|
||||||
props: {
|
props: {
|
||||||
@@ -1109,37 +1196,13 @@ describe('chat history', () => {
|
|||||||
if (!swipeSurface)
|
if (!swipeSurface)
|
||||||
throw new Error('Expected a message swipe surface.')
|
throw new Error('Expected a message swipe surface.')
|
||||||
|
|
||||||
swipeSurface.dispatchEvent(new PointerEvent('pointerdown', {
|
dispatchTouchEvent(swipeSurface, 'touchstart', 100)
|
||||||
bubbles: true,
|
dispatchTouchEvent(swipeSurface, 'touchmove', 40)
|
||||||
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',
|
|
||||||
}))
|
|
||||||
await vi.waitFor(() => {
|
await vi.waitFor(() => {
|
||||||
expect(swipeSurface.dataset.swipeActive).toBe('true')
|
expect(swipeSurface.dataset.swipeActive).toBe('true')
|
||||||
})
|
})
|
||||||
|
|
||||||
swipeSurface.dispatchEvent(new PointerEvent('pointercancel', {
|
dispatchTouchEvent(swipeSurface, 'touchcancel', 40)
|
||||||
bubbles: true,
|
|
||||||
buttons: 0,
|
|
||||||
clientX: 40,
|
|
||||||
clientY: 62,
|
|
||||||
isPrimary: true,
|
|
||||||
pointerId: 1,
|
|
||||||
pointerType: 'mouse',
|
|
||||||
}))
|
|
||||||
|
|
||||||
await vi.waitFor(() => {
|
await vi.waitFor(() => {
|
||||||
expect(swipeSurface.dataset.swipeActive).toBe('false')
|
expect(swipeSurface.dataset.swipeActive).toBe('false')
|
||||||
|
|||||||
Reference in New Issue
Block a user