From 2904b795dd9a911855a174ad640ab81faaf6051c Mon Sep 17 00:00:00 2001 From: Neko Date: Thu, 10 Sep 2026 21:42:17 +0800 Subject: [PATCH] fix(stage-ui): keep mobile swipe attached to touch (#2514) --- .../src/components/gestures/swipeable.ts | 2 +- .../src/components/gestures/swipeable.vue | 58 ++++-- .../chat/components/history.browser.test.ts | 184 +++++++++++++++++- 3 files changed, 223 insertions(+), 21 deletions(-) diff --git a/packages/stage-ui/src/components/gestures/swipeable.ts b/packages/stage-ui/src/components/gestures/swipeable.ts index d333d3191..63e224787 100644 --- a/packages/stage-ui/src/components/gestures/swipeable.ts +++ b/packages/stage-ui/src/components/gestures/swipeable.ts @@ -12,7 +12,7 @@ export interface SwipeableProps { input?: SwipeableInput /** Selects the horizontal direction that commits the action. @default 'left' */ 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 /** Commits the action when the directed distance reaches this value, in pixels. @default 48 */ threshold?: number diff --git a/packages/stage-ui/src/components/gestures/swipeable.vue b/packages/stage-ui/src/components/gestures/swipeable.vue index eda7499ee..736a81b81 100644 --- a/packages/stage-ui/src/components/gestures/swipeable.vue +++ b/packages/stage-ui/src/components/gestures/swipeable.vue @@ -54,6 +54,9 @@ const slotProps = computed(() => ({ })) let returnAnimation: ReturnType | 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 wheelIntent: 'pending' | 'horizontal' | 'vertical' = 'pending' let wheelSessionActive = false @@ -76,17 +79,12 @@ function mapGestureDistance(distance: number) { return resistanceLength * -Math.expm1(-distance / resistanceLength) } -function setGestureDistance(distance: number) { +function setVisualDistance(distance: number) { const positiveDistance = Math.max(0, distance) const visibleDistance = mapGestureDistance(positiveDistance) const direction = props.direction === 'left' ? -1 : 1 pendingPositionX = direction * visibleDistance - const crossed = positiveDistance >= props.threshold - if (crossed && !thresholdCrossed.value) - emit('thresholdEnter') - thresholdCrossed.value = crossed - if (positionFrame !== undefined) 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() { returnAnimation?.cancel() if (positionFrame !== undefined) { @@ -126,22 +134,41 @@ function beginTouchSwipe() { if (!props.enabled || props.input !== 'touch') return - returnAnimation?.cancel() + touchIntent = 'pending' } function updateTouchSwipe() { if (!props.enabled || props.input !== 'touch') return + if (touchIntent === 'vertical') + return + const deltaX = touchDistanceX.value const deltaY = Math.abs(touchDistanceY.value) const distance = directedDistance(deltaX) - if (Math.max(Math.abs(deltaX), deltaY) < props.startDistance) - return + const absoluteDeltaX = Math.abs(deltaX) - if (distance <= 0 || deltaY >= distance) { - setGestureDistance(0) - return + if (touchIntent === 'pending') { + // Keep the message under the finger while the nested press animation is + // 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() @@ -153,7 +180,11 @@ function finishTouchSwipe(event: TouchEvent) { if (props.input !== 'touch') return - const shouldCommit = event.type === 'touchend' && props.enabled && thresholdCrossed.value + const shouldCommit = event.type === 'touchend' + && touchIntent === 'horizontal' + && props.enabled + && thresholdCrossed.value + touchIntent = 'pending' resetPosition() if (shouldCommit) emit('commit') @@ -175,6 +206,7 @@ function finishWheelSwipe() { } function cancelGesture() { + touchIntent = 'pending' wheelIntent = 'pending' wheelSessionActive = false wheelDistance = 0 diff --git a/packages/stage-ui/src/components/scenarios/chat/components/history.browser.test.ts b/packages/stage-ui/src/components/scenarios/chat/components/history.browser.test.ts index eb940897b..f8a5497e1 100644 --- a/packages/stage-ui/src/components/scenarios/chat/components/history.browser.test.ts +++ b/packages/stage-ui/src/components/scenarios/chat/components/history.browser.test.ts @@ -2,7 +2,7 @@ import type { ChatHistoryItem } from '../../../../types/chat' 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 { nextTick } from 'vue' import { createI18n } from 'vue-i18n' @@ -11,6 +11,12 @@ import ChatHistory from './history.vue' import { getChatHistoryItemKey } from '../utils' +const triggerHaptic = vi.fn() + +vi.mock('web-haptics/vue', () => ({ + useWebHaptics: () => ({ trigger: triggerHaptic }), +})) + function createEnglishI18n() { return createI18n({ 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({ clientX, - clientY: 60, + clientY, identifier: 1, target: element, }) @@ -117,6 +128,10 @@ function dispatchTouchEvent(element: HTMLElement, type: 'touchstart' | 'touchmov } describe('chat history', () => { + beforeEach(() => { + triggerHaptic.mockClear() + }) + it('renders a stored reply relation inside the message bubble', async () => { const screen = await render(ChatHistory, { 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('[data-swipeable]') + const swipeSurface = screen.container.querySelector('[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('[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('[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 // ROOT CAUSE: // @@ -1291,7 +1447,7 @@ describe('chat history', () => { 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, { props: { messages: [{ id: 'press-target', role: 'user', content: 'Press target' }], @@ -1307,20 +1463,34 @@ describe('chat history', () => { expect(screen.container.querySelector('[data-pressing]')).not.toBeNull() }) const trigger = screen.container.querySelector('[data-pressing]') - if (!trigger) + const swipeRoot = screen.container.querySelector('[data-swipeable]') + const swipeSurface = screen.container.querySelector('[data-swipeable-surface]') + if (!trigger || !swipeRoot || !swipeSurface) throw new Error('Expected a chat action menu trigger.') dispatchTouchPointer(trigger, 'pointerdown', 100) + dispatchTouchEvent(trigger, 'touchstart', 100) await vi.waitFor(() => { expect(trigger.dataset.pressing).toBe('true') }) - // The swipe surface captures a confirmed horizontal gesture after this - // movement. Press feedback observes the same travel at window level. + dispatchTouchPointer(window, 'pointermove', 96) + 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) + dispatchTouchEvent(trigger, 'touchmove', 80) await vi.waitFor(() => { 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 () => {