Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 88 additions & 21 deletions packages/hooks/src/useLongPress/__tests__/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,33 @@ const mockCallback = jest.fn();
const mockClickCallback = jest.fn();
const mockLongPressEndCallback = jest.fn();

let events = {};
let events: Record<string, (event?: any) => void> = {};

const mockTarget = {
addEventListener: jest.fn((event, callback) => {
events[event] = callback;
}),
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) =>
Expand All @@ -22,6 +41,7 @@ const setup = (onLongPress: any, target, options?: Options) =>
describe('useLongPress', () => {
beforeEach(() => {
jest.useFakeTimers();
jest.clearAllMocks();
});

afterEach(() => {
Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -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`, () => {
Expand All @@ -103,7 +170,7 @@ describe('useLongPress', () => {
},
});

setup(() => {}, mockTarget);
setup(() => { }, mockTarget);
expect(Object.keys(events)).toHaveLength(0);
});
});
121 changes: 36 additions & 85 deletions packages/hooks/src/useLongPress/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type { BasicTarget } from '../utils/domTarget';
import { getTargetElement } from '../utils/domTarget';
import useEffectWithTarget from '../utils/useEffectWithTarget';

type EventType = MouseEvent | TouchEvent;
type EventType = PointerEvent;
export interface Options {
delay?: number;
moveThreshold?: { x?: number; y?: number };
Expand All @@ -25,8 +25,8 @@ function useLongPress(

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<number | null>(null);
const hasMoveThreshold = !!(
(moveThreshold?.x && moveThreshold.x > 0) ||
(moveThreshold?.y && moveThreshold.y > 0)
Expand All @@ -40,80 +40,55 @@ 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) ||
(moveThreshold?.y && offsetY > moveThreshold.y)
);
};

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,
};
}

return { clientX: 0, clientY: 0 };
}

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;
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);
};

const onMouseDown = (event: MouseEvent) => {
if ((event as any)?.sourceCapabilities?.firesTouchEvents) {
return;
}
isPressed.current = true;
pointerId.current = event.pointerId;

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;

isPressed.current = false;
pointerId.current = null;

if (timerRef.current) {
clearTimeout(timerRef.current);
Expand All @@ -125,56 +100,35 @@ function useLongPress(
} 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;
const onPointerCancel = (event: PointerEvent) => {
if (!isPressed.current || event.pointerId !== pointerId.current) return;

if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = undefined;
}
isPressed.current = false;
pointerId.current = null;

if (isTriggeredRef.current) {
onLongPressEndRef.current?.(event);
} else if (onClickRef.current) {
onClickRef.current(event);
}
isTriggeredRef.current = false;
};

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);
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 () => {
Expand All @@ -183,15 +137,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);
}
};
},
Expand All @@ -200,4 +151,4 @@ function useLongPress(
);
}

export default useLongPress;
export default useLongPress;
Loading