From 61ac00689b4dd46520c73fe874492c4834dba54a Mon Sep 17 00:00:00 2001 From: Zephyr Date: Wed, 28 May 2025 19:16:46 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20=E4=BF=AE=E6=94=B9=20useLonePress=20?= =?UTF-8?q?=E5=9C=A8=E7=89=B9=E6=AE=8A=E8=AE=BE=E5=A4=87=E3=80=81=E7=89=B9?= =?UTF-8?q?=E6=AE=8A=E6=83=85=E5=86=B5=E4=B8=8B=E9=95=BF=E6=8C=89=E4=BA=8B?= =?UTF-8?q?=E4=BB=B6=E4=B8=8D=E8=A7=A6=E5=8F=91=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/hooks/src/useLongPress/index.ts | 119 ++++++++++++++++------- 1 file changed, 85 insertions(+), 34 deletions(-) diff --git a/packages/hooks/src/useLongPress/index.ts b/packages/hooks/src/useLongPress/index.ts index caa3aee13a..87cd6e1d0e 100644 --- a/packages/hooks/src/useLongPress/index.ts +++ b/packages/hooks/src/useLongPress/index.ts @@ -13,11 +13,6 @@ export interface Options { onLongPressEnd?: (event: EventType) => void; } -const touchSupported = - isBrowser && - // @ts-ignore - ('ontouchstart' in window || (window.DocumentTouch && document instanceof DocumentTouch)); - function useLongPress( onLongPress: (event: EventType) => void, target: BasicTarget, @@ -30,6 +25,8 @@ function useLongPress( const timerRef = useRef>(); const isTriggeredRef = useRef(false); const pervPositionRef = useRef({ x: 0, y: 0 }); + const mousePressed = useRef(false); + const touchPressed = useRef(false); const hasMoveThreshold = !!( (moveThreshold?.x && moveThreshold.x > 0) || (moveThreshold?.y && moveThreshold.y > 0) @@ -60,7 +57,6 @@ function useLongPress( clientY: event.touches[0].clientY, }; } - if (event instanceof MouseEvent) { return { clientX: event.clientX, @@ -73,64 +69,119 @@ function useLongPress( return { clientX: 0, clientY: 0 }; } - const onStart = (event: EventType) => { + const createTimer = (event: EventType) => { + timerRef.current = setTimeout(() => { + onLongPressRef.current(event); + isTriggeredRef.current = true; + }, delay); + }; + + const onTouchStart = (event: TouchEvent) => { + if (touchPressed.current) return; + touchPressed.current = true; + if (hasMoveThreshold) { const { clientX, clientY } = getClientPosition(event); pervPositionRef.current.x = clientX; pervPositionRef.current.y = clientY; } - timerRef.current = setTimeout(() => { - onLongPressRef.current(event); - isTriggeredRef.current = true; - }, delay); + createTimer(event); }; - const onMove = (event: TouchEvent) => { + const onMouseDown = (event: MouseEvent) => { + if ((event as any)?.sourceCapabilities?.firesTouchEvents) return; + + mousePressed.current = true; + + if (hasMoveThreshold) { + pervPositionRef.current.x = event.clientX; + pervPositionRef.current.y = event.clientY; + } + createTimer(event); + }; + + const onMove = (event: EventType) => { if (timerRef.current && overThreshold(event)) { clearTimeout(timerRef.current); timerRef.current = undefined; } }; - const onEnd = (event: EventType, shouldTriggerClick: boolean = false) => { + const onTouchEnd = (event: TouchEvent) => { + if (!touchPressed.current) return; + touchPressed.current = false; + if (timerRef.current) { clearTimeout(timerRef.current); + timerRef.current = undefined; } + if (isTriggeredRef.current) { onLongPressEndRef.current?.(event); + } else if (onClickRef.current) { + onClickRef.current(event); + } + isTriggeredRef.current = false; + }; + + const onMouseUp = (event: MouseEvent) => { + if ((event as any)?.sourceCapabilities?.firesTouchEvents) return; + if (!mousePressed.current) return; + mousePressed.current = false; + + if (timerRef.current) { + clearTimeout(timerRef.current); + timerRef.current = undefined; } - if (shouldTriggerClick && !isTriggeredRef.current && onClickRef.current) { + + if (isTriggeredRef.current) { + onLongPressEndRef.current?.(event); + } else if (onClickRef.current) { onClickRef.current(event); } isTriggeredRef.current = false; }; - const onEndWithClick = (event: EventType) => onEnd(event, true); - - if (!touchSupported) { - targetElement.addEventListener('mousedown', onStart); - targetElement.addEventListener('mouseup', onEndWithClick); - targetElement.addEventListener('mouseleave', onEnd); - if (hasMoveThreshold) targetElement.addEventListener('mousemove', onMove); - } else { - targetElement.addEventListener('touchstart', onStart); - targetElement.addEventListener('touchend', onEndWithClick); - if (hasMoveThreshold) targetElement.addEventListener('touchmove', onMove); + const onMouseLeave = (event: MouseEvent) => { + if (!mousePressed.current) return; + mousePressed.current = false; + + if (timerRef.current) { + clearTimeout(timerRef.current); + timerRef.current = undefined; + } + if (isTriggeredRef.current) { + onLongPressEndRef.current?.(event); + isTriggeredRef.current = false; + } + }; + + targetElement.addEventListener('mousedown', onMouseDown); + targetElement.addEventListener('mouseup', onMouseUp); + targetElement.addEventListener('mouseleave', onMouseLeave); + targetElement.addEventListener('touchstart', onTouchStart); + targetElement.addEventListener('touchend', onTouchEnd); + + if (hasMoveThreshold) { + targetElement.addEventListener('mousemove', onMove); + targetElement.addEventListener('touchmove', onMove); } + return () => { if (timerRef.current) { clearTimeout(timerRef.current); isTriggeredRef.current = false; } - if (!touchSupported) { - targetElement.removeEventListener('mousedown', onStart); - targetElement.removeEventListener('mouseup', onEndWithClick); - targetElement.removeEventListener('mouseleave', onEnd); - if (hasMoveThreshold) targetElement.removeEventListener('mousemove', onMove); - } else { - targetElement.removeEventListener('touchstart', onStart); - targetElement.removeEventListener('touchend', onEndWithClick); - if (hasMoveThreshold) targetElement.removeEventListener('touchmove', onMove); + + targetElement.removeEventListener('mousedown', onMouseDown); + targetElement.removeEventListener('mouseup', onMouseUp); + targetElement.removeEventListener('mouseleave', onMouseLeave); + targetElement.removeEventListener('touchstart', onTouchStart); + targetElement.removeEventListener('touchend', onTouchEnd); + + if (hasMoveThreshold) { + targetElement.removeEventListener('mousemove', onMove); + targetElement.removeEventListener('touchmove', onMove); } }; }, From f268971f1c0ab5150482af431640f7a3e042f416 Mon Sep 17 00:00:00 2001 From: Zephyr Date: Thu, 5 Jun 2025 10:44:17 +0800 Subject: [PATCH 2/2] =?UTF-8?q?refactor:=20useLongPress=20hooks=20?= =?UTF-8?q?=E5=86=85=E9=83=A8=E5=AE=9E=E7=8E=B0=E4=BD=BF=E7=94=A8=20pointe?= =?UTF-8?q?r=20=E4=BA=8B=E4=BB=B6=E6=9B=BF=E4=BB=A3=20mouse/touch=20?= =?UTF-8?q?=E4=BA=8B=E4=BB=B6=20&&=20=E6=B5=8B=E8=AF=95=E7=94=A8=E4=BE=8B?= =?UTF-8?q?=E9=87=8D=E5=86=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/useLongPress/__tests__/index.test.ts | 109 +++++++++++++---- packages/hooks/src/useLongPress/index.ts | 112 ++++++------------ 2 files changed, 122 insertions(+), 99 deletions(-) diff --git a/packages/hooks/src/useLongPress/__tests__/index.test.ts b/packages/hooks/src/useLongPress/__tests__/index.test.ts index 4084346e72..617a16c37d 100644 --- a/packages/hooks/src/useLongPress/__tests__/index.test.ts +++ b/packages/hooks/src/useLongPress/__tests__/index.test.ts @@ -6,7 +6,8 @@ const mockCallback = jest.fn(); const mockClickCallback = jest.fn(); const mockLongPressEndCallback = jest.fn(); -let events = {}; +let events: Record void> = {}; + const mockTarget = { addEventListener: jest.fn((event, callback) => { events[event] = callback; @@ -14,6 +15,24 @@ const mockTarget = { removeEventListener: jest.fn((event) => { Reflect.deleteProperty(events, event); }), + setPointerCapture: jest.fn(), +}; + +// 模拟 PointerEvent +const createPointerEvent = ( + type: string, + pointerId = 1, + clientX = 0, + clientY = 0, +): PointerEvent => { + return { + type, + pointerId, + clientX, + clientY, + preventDefault: jest.fn(), + stopPropagation: jest.fn(), + } as unknown as PointerEvent; }; const setup = (onLongPress: any, target, options?: Options) => @@ -22,6 +41,7 @@ const setup = (onLongPress: any, target, options?: Options) => describe('useLongPress', () => { beforeEach(() => { jest.useFakeTimers(); + jest.clearAllMocks(); }); afterEach(() => { @@ -35,9 +55,16 @@ describe('useLongPress', () => { onLongPressEnd: mockLongPressEndCallback, }); expect(mockTarget.addEventListener).toBeCalled(); - events['mousedown'](); + + const pointerDownEvent = createPointerEvent('pointerdown'); + events.pointerdown(pointerDownEvent); + expect(mockTarget.setPointerCapture).toBeCalledWith(pointerDownEvent.pointerId); + jest.advanceTimersByTime(350); - events['mouseleave'](); + + const pointerCancelEvent = createPointerEvent('pointercancel', pointerDownEvent.pointerId); + events.pointercancel(pointerCancelEvent); + expect(mockCallback).toBeCalledTimes(1); expect(mockLongPressEndCallback).toBeCalledTimes(1); expect(mockClickCallback).toBeCalledTimes(0); @@ -49,10 +76,19 @@ describe('useLongPress', () => { onLongPressEnd: mockLongPressEndCallback, }); expect(mockTarget.addEventListener).toBeCalled(); - events['mousedown'](); - events['mouseup'](); - events['mousedown'](); - events['mouseup'](); + + const pointerDown1 = createPointerEvent('pointerdown', 1); + events.pointerdown(pointerDown1); + + const pointerUp1 = createPointerEvent('pointerup', 1); + events.pointerup(pointerUp1); + + const pointerDown2 = createPointerEvent('pointerdown', 2); + events.pointerdown(pointerDown2); + + const pointerUp2 = createPointerEvent('pointerup', 2); + events.pointerup(pointerUp2); + expect(mockCallback).toBeCalledTimes(0); expect(mockLongPressEndCallback).toBeCalledTimes(0); expect(mockClickCallback).toBeCalledTimes(2); @@ -64,11 +100,20 @@ describe('useLongPress', () => { onLongPressEnd: mockLongPressEndCallback, }); expect(mockTarget.addEventListener).toBeCalled(); - events['mousedown'](); + + const longPressDown = createPointerEvent('pointerdown', 1); + events.pointerdown(longPressDown); jest.advanceTimersByTime(350); - events['mouseup'](); - events['mousedown'](); - events['mouseup'](); + + const longPressUp = createPointerEvent('pointerup', 1); + events.pointerup(longPressUp); + + const clickDown = createPointerEvent('pointerdown', 2); + events.pointerdown(clickDown); + + const clickUp = createPointerEvent('pointerup', 2); + events.pointerup(clickUp); + expect(mockCallback).toBeCalledTimes(1); expect(mockLongPressEndCallback).toBeCalledTimes(1); expect(mockClickCallback).toBeCalledTimes(1); @@ -81,19 +126,41 @@ describe('useLongPress', () => { y: 20, }, }); - expect(events['mousemove']).toBeDefined(); - events['mousedown'](new MouseEvent('mousedown')); - events['mousemove']( - new MouseEvent('mousemove', { - clientX: 40, - clientY: 10, - }), - ); + expect(events.pointermove).toBeDefined(); + + const pointerDown = createPointerEvent('pointerdown', 1, 0, 0); + events.pointerdown(pointerDown); + + const pointerMove = createPointerEvent('pointermove', 1, 40, 10); + events.pointermove(pointerMove); + jest.advanceTimersByTime(320); expect(mockCallback).not.toBeCalled(); unmount(); - expect(events['mousemove']).toBeUndefined(); + expect(events.pointermove).toBeUndefined(); + }); + + it('should handle multiple pointer interactions correctly', () => { + setup(mockCallback, mockTarget); + + const pointer1Down = createPointerEvent('pointerdown', 1); + events.pointerdown(pointer1Down); + + const pointer2Down = createPointerEvent('pointerdown', 2); + events.pointerdown(pointer2Down); + + jest.advanceTimersByTime(350); + + const pointer2Up = createPointerEvent('pointerup', 2); + events.pointerup(pointer2Up); + + const pointer1Up = createPointerEvent('pointerup', 1); + events.pointerup(pointer1Up); + + expect(mockCallback).toBeCalledTimes(1); + expect(mockTarget.setPointerCapture).toBeCalledWith(1); + expect(mockTarget.setPointerCapture).toBeCalledTimes(1); }); it(`should not work when target don't support addEventListener method`, () => { @@ -103,7 +170,7 @@ describe('useLongPress', () => { }, }); - setup(() => {}, mockTarget); + setup(() => { }, mockTarget); expect(Object.keys(events)).toHaveLength(0); }); }); diff --git a/packages/hooks/src/useLongPress/index.ts b/packages/hooks/src/useLongPress/index.ts index 87cd6e1d0e..d11525100b 100644 --- a/packages/hooks/src/useLongPress/index.ts +++ b/packages/hooks/src/useLongPress/index.ts @@ -2,10 +2,9 @@ import { useRef } from 'react'; import useLatest from '../useLatest'; import type { BasicTarget } from '../utils/domTarget'; import { getTargetElement } from '../utils/domTarget'; -import isBrowser from '../utils/isBrowser'; import useEffectWithTarget from '../utils/useEffectWithTarget'; -type EventType = MouseEvent | TouchEvent; +type EventType = PointerEvent; export interface Options { delay?: number; moveThreshold?: { x?: number; y?: number }; @@ -25,8 +24,8 @@ function useLongPress( const timerRef = useRef>(); const isTriggeredRef = useRef(false); const pervPositionRef = useRef({ x: 0, y: 0 }); - const mousePressed = useRef(false); - const touchPressed = useRef(false); + const isPressed = useRef(false); + const pointerId = useRef(null); const hasMoveThreshold = !!( (moveThreshold?.x && moveThreshold.x > 0) || (moveThreshold?.y && moveThreshold.y > 0) @@ -40,9 +39,8 @@ function useLongPress( } const overThreshold = (event: EventType) => { - const { clientX, clientY } = getClientPosition(event); - const offsetX = Math.abs(clientX - pervPositionRef.current.x); - const offsetY = Math.abs(clientY - pervPositionRef.current.y); + const offsetX = Math.abs(event.clientX - pervPositionRef.current.x); + const offsetY = Math.abs(event.clientY - pervPositionRef.current.y); return !!( (moveThreshold?.x && offsetX > moveThreshold.x) || @@ -50,25 +48,6 @@ function useLongPress( ); }; - function getClientPosition(event: EventType) { - if ('TouchEvent' in window && event instanceof TouchEvent) { - return { - clientX: event.touches[0].clientX, - clientY: event.touches[0].clientY, - }; - } - if (event instanceof MouseEvent) { - return { - clientX: event.clientX, - clientY: event.clientY, - }; - } - - console.warn('Unsupported event type'); - - return { clientX: 0, clientY: 0 }; - } - const createTimer = (event: EventType) => { timerRef.current = setTimeout(() => { onLongPressRef.current(event); @@ -76,58 +55,37 @@ function useLongPress( }, delay); }; - const onTouchStart = (event: TouchEvent) => { - if (touchPressed.current) return; - touchPressed.current = true; + const onPointerDown = (event: PointerEvent) => { + if (isPressed.current) return; - if (hasMoveThreshold) { - const { clientX, clientY } = getClientPosition(event); - pervPositionRef.current.x = clientX; - pervPositionRef.current.y = clientY; - } - createTimer(event); - }; + isPressed.current = true; + pointerId.current = event.pointerId; - const onMouseDown = (event: MouseEvent) => { - if ((event as any)?.sourceCapabilities?.firesTouchEvents) return; - - mousePressed.current = true; + // 捕获指针以确保即使指针移出元素也能接收到事件 + targetElement.setPointerCapture(event.pointerId); if (hasMoveThreshold) { pervPositionRef.current.x = event.clientX; pervPositionRef.current.y = event.clientY; } + createTimer(event); }; - const onMove = (event: EventType) => { + const onPointerMove = (event: PointerEvent) => { + if (!isPressed.current || event.pointerId !== pointerId.current) return; + if (timerRef.current && overThreshold(event)) { clearTimeout(timerRef.current); timerRef.current = undefined; } }; - const onTouchEnd = (event: TouchEvent) => { - if (!touchPressed.current) return; - touchPressed.current = false; + const onPointerUp = (event: PointerEvent) => { + if (!isPressed.current || event.pointerId !== pointerId.current) return; - if (timerRef.current) { - clearTimeout(timerRef.current); - timerRef.current = undefined; - } - - if (isTriggeredRef.current) { - onLongPressEndRef.current?.(event); - } else if (onClickRef.current) { - onClickRef.current(event); - } - isTriggeredRef.current = false; - }; - - const onMouseUp = (event: MouseEvent) => { - if ((event as any)?.sourceCapabilities?.firesTouchEvents) return; - if (!mousePressed.current) return; - mousePressed.current = false; + isPressed.current = false; + pointerId.current = null; if (timerRef.current) { clearTimeout(timerRef.current); @@ -139,32 +97,33 @@ function useLongPress( } else if (onClickRef.current) { onClickRef.current(event); } + isTriggeredRef.current = false; }; - const onMouseLeave = (event: MouseEvent) => { - if (!mousePressed.current) return; - mousePressed.current = false; + const onPointerCancel = (event: PointerEvent) => { + if (!isPressed.current || event.pointerId !== pointerId.current) return; + + isPressed.current = false; + pointerId.current = null; if (timerRef.current) { clearTimeout(timerRef.current); timerRef.current = undefined; } + if (isTriggeredRef.current) { onLongPressEndRef.current?.(event); isTriggeredRef.current = false; } }; - targetElement.addEventListener('mousedown', onMouseDown); - targetElement.addEventListener('mouseup', onMouseUp); - targetElement.addEventListener('mouseleave', onMouseLeave); - targetElement.addEventListener('touchstart', onTouchStart); - targetElement.addEventListener('touchend', onTouchEnd); + targetElement.addEventListener('pointerdown', onPointerDown); + targetElement.addEventListener('pointerup', onPointerUp); + targetElement.addEventListener('pointercancel', onPointerCancel); if (hasMoveThreshold) { - targetElement.addEventListener('mousemove', onMove); - targetElement.addEventListener('touchmove', onMove); + targetElement.addEventListener('pointermove', onPointerMove); } return () => { @@ -173,15 +132,12 @@ function useLongPress( isTriggeredRef.current = false; } - targetElement.removeEventListener('mousedown', onMouseDown); - targetElement.removeEventListener('mouseup', onMouseUp); - targetElement.removeEventListener('mouseleave', onMouseLeave); - targetElement.removeEventListener('touchstart', onTouchStart); - targetElement.removeEventListener('touchend', onTouchEnd); + targetElement.removeEventListener('pointerdown', onPointerDown); + targetElement.removeEventListener('pointerup', onPointerUp); + targetElement.removeEventListener('pointercancel', onPointerCancel); if (hasMoveThreshold) { - targetElement.removeEventListener('mousemove', onMove); - targetElement.removeEventListener('touchmove', onMove); + targetElement.removeEventListener('pointermove', onPointerMove); } }; },