fix(stage-ui): keep mobile swipe attached to touch (#2514)
This commit is contained in:
@@ -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
|
||||||
|
|||||||
+177
-7
@@ -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 () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user