fix(stage-ui): keep mobile swipe attached to touch (#2514)

This commit is contained in:
Neko
2026-09-10 21:42:17 +08:00
committed by GitHub
parent 9a76eaec32
commit 2904b795dd
3 changed files with 223 additions and 21 deletions
@@ -12,7 +12,7 @@ export interface SwipeableProps {
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 touch or wheel jitter below this distance, in pixels. @default 8 */ /** Locks touch or wheel intent after this travel 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
@@ -54,6 +54,9 @@ const slotProps = computed<SwipeableSlotProps>(() => ({
})) }))
let returnAnimation: ReturnType<typeof animate> | undefined let returnAnimation: ReturnType<typeof animate> | undefined
// Touch travel stays pending while tap feedback and swipe movement overlap. Once
// an axis is clear, its intent remains locked until touchend or touchcancel.
let touchIntent: 'pending' | 'horizontal' | 'vertical' = 'pending'
let wheelDistance = 0 let wheelDistance = 0
let wheelIntent: 'pending' | 'horizontal' | 'vertical' = 'pending' let wheelIntent: 'pending' | 'horizontal' | 'vertical' = 'pending'
let wheelSessionActive = false let wheelSessionActive = false
@@ -76,17 +79,12 @@ function mapGestureDistance(distance: number) {
return resistanceLength * -Math.expm1(-distance / resistanceLength) return resistanceLength * -Math.expm1(-distance / resistanceLength)
} }
function setGestureDistance(distance: number) { function setVisualDistance(distance: number) {
const positiveDistance = Math.max(0, distance) const positiveDistance = Math.max(0, distance)
const visibleDistance = mapGestureDistance(positiveDistance) const visibleDistance = mapGestureDistance(positiveDistance)
const direction = props.direction === 'left' ? -1 : 1 const direction = props.direction === 'left' ? -1 : 1
pendingPositionX = direction * visibleDistance pendingPositionX = direction * visibleDistance
const crossed = positiveDistance >= props.threshold
if (crossed && !thresholdCrossed.value)
emit('thresholdEnter')
thresholdCrossed.value = crossed
if (positionFrame !== undefined) if (positionFrame !== undefined)
return return
@@ -96,6 +94,16 @@ function setGestureDistance(distance: number) {
}) })
} }
function setGestureDistance(distance: number) {
const positiveDistance = Math.max(0, distance)
setVisualDistance(positiveDistance)
const crossed = positiveDistance >= props.threshold
if (crossed && !thresholdCrossed.value)
emit('thresholdEnter')
thresholdCrossed.value = crossed
}
function animatePositionToRest() { function animatePositionToRest() {
returnAnimation?.cancel() returnAnimation?.cancel()
if (positionFrame !== undefined) { if (positionFrame !== undefined) {
@@ -126,22 +134,41 @@ function beginTouchSwipe() {
if (!props.enabled || props.input !== 'touch') if (!props.enabled || props.input !== 'touch')
return return
returnAnimation?.cancel() touchIntent = 'pending'
} }
function updateTouchSwipe() { function updateTouchSwipe() {
if (!props.enabled || props.input !== 'touch') if (!props.enabled || props.input !== 'touch')
return return
if (touchIntent === 'vertical')
return
const deltaX = touchDistanceX.value const deltaX = touchDistanceX.value
const deltaY = Math.abs(touchDistanceY.value) const deltaY = Math.abs(touchDistanceY.value)
const distance = directedDistance(deltaX) const distance = directedDistance(deltaX)
if (Math.max(Math.abs(deltaX), deltaY) < props.startDistance) const absoluteDeltaX = Math.abs(deltaX)
return
if (distance <= 0 || deltaY >= distance) { if (touchIntent === 'pending') {
setGestureDistance(0) // Keep the message under the finger while the nested press animation is
return // still active. startDistance decides intent; it is not a visual dead zone.
returnAnimation?.cancel()
setVisualDistance(distance)
if (Math.max(absoluteDeltaX, deltaY) < props.startDistance)
return
if (deltaY >= absoluteDeltaX) {
touchIntent = 'vertical'
animatePositionToRest()
return
}
if (distance <= 0)
return
touchIntent = 'horizontal'
active.value = true
} }
returnAnimation?.cancel() returnAnimation?.cancel()
@@ -153,7 +180,11 @@ function finishTouchSwipe(event: TouchEvent) {
if (props.input !== 'touch') if (props.input !== 'touch')
return return
const shouldCommit = event.type === 'touchend' && props.enabled && thresholdCrossed.value const shouldCommit = event.type === 'touchend'
&& touchIntent === 'horizontal'
&& props.enabled
&& thresholdCrossed.value
touchIntent = 'pending'
resetPosition() resetPosition()
if (shouldCommit) if (shouldCommit)
emit('commit') emit('commit')
@@ -175,6 +206,7 @@ function finishWheelSwipe() {
} }
function cancelGesture() { function cancelGesture() {
touchIntent = 'pending'
wheelIntent = 'pending' wheelIntent = 'pending'
wheelSessionActive = false wheelSessionActive = false
wheelDistance = 0 wheelDistance = 0
@@ -2,7 +2,7 @@ import type { ChatHistoryItem } from '../../../../types/chat'
import en from '@proj-airi/i18n/locales/en' import en from '@proj-airi/i18n/locales/en'
import { describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import { render } from 'vitest-browser-vue' import { render } from 'vitest-browser-vue'
import { nextTick } from 'vue' import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n' import { createI18n } from 'vue-i18n'
@@ -11,6 +11,12 @@ import ChatHistory from './history.vue'
import { getChatHistoryItemKey } from '../utils' import { getChatHistoryItemKey } from '../utils'
const triggerHaptic = vi.fn()
vi.mock('web-haptics/vue', () => ({
useWebHaptics: () => ({ trigger: triggerHaptic }),
}))
function createEnglishI18n() { function createEnglishI18n() {
return createI18n({ return createI18n({
legacy: false, legacy: false,
@@ -98,10 +104,15 @@ function dispatchTouchPointer(element: EventTarget, type: 'pointerdown' | 'point
})) }))
} }
function dispatchTouchEvent(element: HTMLElement, type: 'touchstart' | 'touchmove' | 'touchend' | 'touchcancel', clientX: number) { function dispatchTouchEvent(
element: HTMLElement,
type: 'touchstart' | 'touchmove' | 'touchend' | 'touchcancel',
clientX: number,
clientY = 60,
) {
const touch = new Touch({ const touch = new Touch({
clientX, clientX,
clientY: 60, clientY,
identifier: 1, identifier: 1,
target: element, target: element,
}) })
@@ -117,6 +128,10 @@ function dispatchTouchEvent(element: HTMLElement, type: 'touchstart' | 'touchmov
} }
describe('chat history', () => { describe('chat history', () => {
beforeEach(() => {
triggerHaptic.mockClear()
})
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, {
props: { props: {
@@ -1111,6 +1126,147 @@ describe('chat history', () => {
}) })
}) })
// ROOT CAUSE:
//
// The touch recognizer discarded movement below its intent threshold. It also
// compared horizontal and vertical travel again on every move. A message first
// jumped to the threshold, then snapped to rest when a confirmed swipe returned
// through the small vertical drift accumulated earlier in the gesture.
//
// The message now follows directed touch travel before intent is confirmed. Once
// horizontal intent is confirmed, that decision lasts until the touch ends.
it('keeps a mobile message attached to the finger before and after the intent threshold', async () => {
const screen = await render(ChatHistory, {
props: {
messages: [{ id: 'continuous-touch-target', role: 'user', content: 'Follow my finger' }],
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 swipeRoot = screen.container.querySelector<HTMLElement>('[data-swipeable]')
const swipeSurface = screen.container.querySelector<HTMLElement>('[data-swipeable-surface]')
if (!swipeRoot || !swipeSurface)
throw new Error('Expected a mobile message swipe surface.')
dispatchTouchEvent(swipeSurface, 'touchstart', 100, 60)
dispatchTouchEvent(swipeSurface, 'touchmove', 96, 61)
await new Promise(resolve => requestAnimationFrame(resolve))
expect(getTranslateX(swipeSurface)).toBeCloseTo(getExpectedLeftSwipeOffset(swipeRoot, 4), 3)
expect(swipeSurface.dataset.swipeActive).toBe('false')
dispatchTouchEvent(swipeSurface, 'touchmove', 40, 70)
await new Promise(resolve => requestAnimationFrame(resolve))
expect(swipeSurface.dataset.swipeActive).toBe('true')
dispatchTouchEvent(swipeSurface, 'touchmove', 92, 70)
await new Promise(resolve => requestAnimationFrame(resolve))
expect(getTranslateX(swipeSurface)).toBeCloseTo(getExpectedLeftSwipeOffset(swipeRoot, 8), 3)
expect(swipeSurface.dataset.swipeActive).toBe('true')
dispatchTouchEvent(swipeSurface, 'touchcancel', 92, 70)
})
// ROOT CAUSE:
//
// Pending touch movement updated both the visual offset and threshold state.
// A diagonal vertical scroll could therefore trigger reply haptics before the
// recognizer locked the gesture to the vertical axis.
//
// Pending movement now updates only the visual offset. Threshold effects start
// after the recognizer confirms horizontal intent.
it('does not trigger reply haptics for a diagonal mobile scroll', async () => {
const screen = await render(ChatHistory, {
props: {
messages: [{ id: 'diagonal-scroll-target', role: 'user', content: 'Scroll target' }],
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.')
dispatchTouchEvent(swipeSurface, 'touchstart', 100, 60)
dispatchTouchEvent(swipeSurface, 'touchmove', 40, 130)
expect(triggerHaptic).not.toHaveBeenCalled()
expect(swipeSurface.dataset.swipeActive).toBe('false')
dispatchTouchEvent(swipeSurface, 'touchend', 40, 130)
expect(screen.emitted('replyMessage')).toBeUndefined()
})
// ROOT CAUSE:
//
// An initial horizontal move opposite the reply direction locked the touch as
// vertical. Later movement in the reply direction was then ignored.
//
// Opposite horizontal movement now stays pending, so the same touch can reverse
// direction and establish horizontal reply intent.
it('allows a mobile touch to reverse from the opposite horizontal direction', async () => {
const message: ChatHistoryItem = {
id: 'reversing-touch-target',
role: 'user',
content: 'Reverse target',
}
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.')
dispatchTouchEvent(swipeSurface, 'touchstart', 100, 60)
dispatchTouchEvent(swipeSurface, 'touchmove', 96, 61)
await new Promise(resolve => requestAnimationFrame(resolve))
expect(getTranslateX(swipeSurface)).toBeLessThan(0)
dispatchTouchEvent(swipeSurface, 'touchmove', 112, 61)
await new Promise(resolve => requestAnimationFrame(resolve))
expect(triggerHaptic).not.toHaveBeenCalled()
expect(swipeSurface.dataset.swipeActive).toBe('false')
expect(getTranslateX(swipeSurface)).toBe(0)
dispatchTouchEvent(swipeSurface, 'touchmove', 40, 62)
dispatchTouchEvent(swipeSurface, 'touchend', 40, 62)
expect(triggerHaptic).toHaveBeenCalledExactlyOnceWith('medium')
expect(screen.emitted('replyMessage')).toEqual([[
{
message,
label: 'You',
},
]])
})
// https://github.com/moeru-ai/airi/pull/2489 // https://github.com/moeru-ai/airi/pull/2489
// ROOT CAUSE: // ROOT CAUSE:
// //
@@ -1291,7 +1447,7 @@ describe('chat history', () => {
expect(screen.container.querySelector('.i-solar\\:reply-bold-duotone')).toBeNull() expect(screen.container.querySelector('.i-solar\\:reply-bold-duotone')).toBeNull()
}) })
it('cancels mobile press feedback when a swipe starts', async () => { it('blends mobile press feedback into a swipe', async () => {
const screen = await render(ChatHistory, { const screen = await render(ChatHistory, {
props: { props: {
messages: [{ id: 'press-target', role: 'user', content: 'Press target' }], messages: [{ id: 'press-target', role: 'user', content: 'Press target' }],
@@ -1307,20 +1463,34 @@ describe('chat history', () => {
expect(screen.container.querySelector('[data-pressing]')).not.toBeNull() expect(screen.container.querySelector('[data-pressing]')).not.toBeNull()
}) })
const trigger = screen.container.querySelector<HTMLElement>('[data-pressing]') const trigger = screen.container.querySelector<HTMLElement>('[data-pressing]')
if (!trigger) const swipeRoot = screen.container.querySelector<HTMLElement>('[data-swipeable]')
const swipeSurface = screen.container.querySelector<HTMLElement>('[data-swipeable-surface]')
if (!trigger || !swipeRoot || !swipeSurface)
throw new Error('Expected a chat action menu trigger.') throw new Error('Expected a chat action menu trigger.')
dispatchTouchPointer(trigger, 'pointerdown', 100) dispatchTouchPointer(trigger, 'pointerdown', 100)
dispatchTouchEvent(trigger, 'touchstart', 100)
await vi.waitFor(() => { await vi.waitFor(() => {
expect(trigger.dataset.pressing).toBe('true') expect(trigger.dataset.pressing).toBe('true')
}) })
// The swipe surface captures a confirmed horizontal gesture after this dispatchTouchPointer(window, 'pointermove', 96)
// movement. Press feedback observes the same travel at window level. dispatchTouchEvent(trigger, 'touchmove', 96)
await new Promise(resolve => requestAnimationFrame(resolve))
expect(trigger.dataset.pressing).toBe('true')
expect(getTranslateX(swipeSurface)).toBeCloseTo(getExpectedLeftSwipeOffset(swipeRoot, 4), 3)
// Press feedback releases after this movement while the same touch stream
// continues to drive the surrounding swipe surface.
dispatchTouchPointer(window, 'pointermove', 80) dispatchTouchPointer(window, 'pointermove', 80)
dispatchTouchEvent(trigger, 'touchmove', 80)
await vi.waitFor(() => { await vi.waitFor(() => {
expect(trigger.dataset.pressing).toBe('false') expect(trigger.dataset.pressing).toBe('false')
expect(swipeSurface.dataset.swipeActive).toBe('true')
}) })
dispatchTouchEvent(trigger, 'touchcancel', 80)
}) })
it('does not apply mobile press feedback to a desktop message', async () => { it('does not apply mobile press feedback to a desktop message', async () => {