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
188 changes: 188 additions & 0 deletions packages/s2-core/__tests__/spreadsheet/mobile-scroll-spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
import { OriginEventType } from '@/common';
import { Canvas, FederatedPointerEvent } from '@antv/g';
import { WheelEvent } from '../../src/facet/mobile/wheelEvent';

describe('Mobile Scroll WheelEvent Tests', () => {
let canvas: Canvas;
let wheelEvent: WheelEvent;

beforeEach(() => {
// Mock Canvas
canvas = {
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
} as unknown as Canvas;
});

afterEach(() => {
wheelEvent?.destroy();
});

test('should bind events on init', () => {
wheelEvent = new WheelEvent(canvas);
expect(canvas.addEventListener).toHaveBeenCalledWith(
OriginEventType.POINTER_DOWN,
expect.any(Function),
);
expect(canvas.addEventListener).toHaveBeenCalledWith(
OriginEventType.POINTER_MOVE,
expect.any(Function),
);
expect(canvas.addEventListener).toHaveBeenCalledWith(
OriginEventType.POINTER_UP,
expect.any(Function),
);
});

test('should remove events on destroy', () => {
wheelEvent = new WheelEvent(canvas);
wheelEvent.destroy();
expect(canvas.removeEventListener).toHaveBeenCalledWith(
OriginEventType.POINTER_DOWN,
expect.any(Function),
);
expect(canvas.removeEventListener).toHaveBeenCalledWith(
OriginEventType.POINTER_MOVE,
expect.any(Function),
);
expect(canvas.removeEventListener).toHaveBeenCalledWith(
OriginEventType.POINTER_UP,
expect.any(Function),
);
});

describe('preventDefault logic', () => {
let mockPreventDefault: jest.Mock;
let mockNativeEvent: any;
let mockEvent: FederatedPointerEvent;

beforeEach(() => {
mockPreventDefault = jest.fn();
mockNativeEvent = {
cancelable: true,
preventDefault: mockPreventDefault,
touches: [{ clientX: 0, clientY: 0 }],
};
mockEvent = {
nativeEvent: mockNativeEvent,
x: 100,
y: 100,
clone: jest.fn().mockReturnValue({}),
} as unknown as FederatedPointerEvent;

jest.spyOn(window, 'cancelAnimationFrame');
jest.spyOn(window, 'requestAnimationFrame');
});

test('should call preventDefault when shouldPreventDefault returns true', () => {
const shouldPreventDefault = jest.fn().mockReturnValue(true);

wheelEvent = new WheelEvent(canvas, shouldPreventDefault);

// Trigger pointer down to start panning
const pointerDownHandler = (
canvas.addEventListener as any
).mock.calls.find(
(call: any[]) => call[0] === OriginEventType.POINTER_DOWN,
)[1];

pointerDownHandler({ ...mockEvent, x: 0, y: 0 });

// Trigger pointer move
const pointerMoveHandler = (
canvas.addEventListener as any
).mock.calls.find(
(call: any[]) => call[0] === OriginEventType.POINTER_MOVE,
)[1];

pointerMoveHandler(mockEvent);

expect(shouldPreventDefault).toHaveBeenCalled();
expect(mockPreventDefault).toHaveBeenCalled();
});

test('should NOT call preventDefault when shouldPreventDefault returns false', () => {
const shouldPreventDefault = jest.fn().mockReturnValue(false);

wheelEvent = new WheelEvent(canvas, shouldPreventDefault);

// Trigger pointer down to start panning
const pointerDownHandler = (
canvas.addEventListener as any
).mock.calls.find(
(call: any[]) => call[0] === OriginEventType.POINTER_DOWN,
)[1];

pointerDownHandler({ ...mockEvent, x: 0, y: 0 });

// Trigger pointer move
const pointerMoveHandler = (
canvas.addEventListener as any
).mock.calls.find(
(call: any[]) => call[0] === OriginEventType.POINTER_MOVE,
)[1];

pointerMoveHandler(mockEvent);

expect(shouldPreventDefault).toHaveBeenCalled();
expect(mockPreventDefault).not.toHaveBeenCalled();
});

test('should call preventDefault by default if shouldPreventDefault is not provided', () => {
wheelEvent = new WheelEvent(canvas);

// Trigger pointer down to start panning
const pointerDownHandler = (
canvas.addEventListener as any
).mock.calls.find(
(call: any[]) => call[0] === OriginEventType.POINTER_DOWN,
)[1];

pointerDownHandler({ ...mockEvent, x: 0, y: 0 });

// Trigger pointer move
const pointerMoveHandler = (
canvas.addEventListener as any
).mock.calls.find(
(call: any[]) => call[0] === OriginEventType.POINTER_MOVE,
)[1];

pointerMoveHandler(mockEvent);

expect(mockPreventDefault).toHaveBeenCalled();
});

test('should NOT call preventDefault if event is not cancelable', () => {
const shouldPreventDefault = jest.fn().mockReturnValue(true);

wheelEvent = new WheelEvent(canvas, shouldPreventDefault);

mockNativeEvent.cancelable = false;

// Trigger pointer down to start panning
const pointerDownHandler = (
canvas.addEventListener as any
).mock.calls.find(
(call: any[]) => call[0] === OriginEventType.POINTER_DOWN,
)[1];

pointerDownHandler({ ...mockEvent, x: 0, y: 0 });

// Trigger pointer move
const pointerMoveHandler = (
canvas.addEventListener as any
).mock.calls.find(
(call: any[]) => call[0] === OriginEventType.POINTER_MOVE,
)[1];

pointerMoveHandler(mockEvent);

expect(shouldPreventDefault).not.toHaveBeenCalled(); // Optimization: check cancelable before calling callback? Not strictly required but typically safer.
// Actually implementation calls callback only if cancelable. Let's check implementation again.
// Implementation: if (nativeEvent?.cancelable) { ... }
// So if not cancelable, logic inside is skipped.

expect(mockPreventDefault).not.toHaveBeenCalled();
});
});
});
86 changes: 65 additions & 21 deletions packages/s2-core/src/facet/base-facet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -705,6 +705,8 @@ export abstract class BaseFacet {

canvas.addEventListener('touchstart', (event) => {
startY = event.touches[0].clientY;
// 重置滚动方向,让新的触摸手势可以向任意方向滚动
this.scrollDirection = undefined as unknown as ScrollDirection;
});

canvas.addEventListener('touchend', (event) => {
Expand Down Expand Up @@ -745,21 +747,56 @@ export abstract class BaseFacet {
};

onContainerWheelForMobile = () => {
this.mobileWheel = new MobileWheel(this.spreadsheet.container);
this.mobileWheel.on('wheel', (ev: FederatedWheelEvent) => {
this.spreadsheet.hideTooltip();
const originEvent = ev.originalEvent;
const { deltaX, deltaY: defaultDeltaY, x, y } = ev;
const deltaY = this.getMobileWheelDeltaY(defaultDeltaY);

this.onWheel({
...originEvent,
// https://github.com/antvis/S2/issues/3249
// 创建回调函数,根据 overscrollBehavior 和滚动边界判断是否阻止默认行为
const shouldPreventDefault = (
deltaX: number,
deltaY: number,
offsetX: number,
offsetY: number,
): boolean => {
const { interaction } = this.spreadsheet.options;
const overscrollBehavior = interaction?.overscrollBehavior;

// 对于 'contain' 和 'none' 模式,始终阻止默认行为
if (overscrollBehavior !== 'auto') {
return true;
}

// 对于 'auto' 模式,只有在滚动区域内(未到边缘)时才阻止默认行为
// 到达边缘时允许事件冒泡到外层容器
const isScrollOverViewport = this.isScrollOverTheViewport({
deltaX,
deltaY,
offsetX: x,
offsetY: y,
} as unknown as WheelEvent);
});
offsetX,
offsetY,
});

return isScrollOverViewport;
};

this.mobileWheel = new MobileWheel(
this.spreadsheet.container,
shouldPreventDefault,
);
this.mobileWheel.on(
'wheel',
(ev: FederatedWheelEvent & { nativeEvent?: Event }) => {
this.spreadsheet.hideTooltip();
const originEvent = ev.originalEvent;
const { deltaX, deltaY: defaultDeltaY, x, y, nativeEvent } = ev;
const deltaY = this.getMobileWheelDeltaY(defaultDeltaY);

this.onWheel({
...originEvent,
deltaX,
deltaY,
offsetX: x,
offsetY: y,
__nativeEvent__: nativeEvent,
} as unknown as WheelEvent);
},
);

this.onContainerWheelForMobileCompatibility();
};
Expand Down Expand Up @@ -1457,16 +1494,20 @@ export abstract class BaseFacet {
};

protected stopScrollChaining = (event: WheelEvent) => {
if (event?.cancelable) {
event?.preventDefault?.();
}
// https://github.com/antvis/S2/issues/3249
// 优先使用 __nativeEvent__ (移动端通过 wheelEvent.ts 传递的原生事件)
// 需要在事件链早期调用 preventDefault,否则事件会变成 passive/non-cancelable
const nativeEvent =
// eslint-disable-next-line no-underscore-dangle
(event as unknown as { __nativeEvent__?: Event })?.__nativeEvent__ ||
(event as unknown as FederatedPointerEvent)?.nativeEvent;

// 使用 G 对应的原生 TouchEvent,以达到移动端禁用外部容器滚动的效果
const mobileEvent =
(event as unknown as FederatedPointerEvent)?.nativeEvent || event;
if (nativeEvent?.cancelable) {
(nativeEvent as Event)?.preventDefault?.();
}

if (mobileEvent?.cancelable) {
mobileEvent?.preventDefault?.();
if (event?.cancelable) {
event?.preventDefault?.();
}
};

Expand Down Expand Up @@ -1517,7 +1558,10 @@ export abstract class BaseFacet {
return;
}

// 水平滚动方向变化检测:只在有水平滚动时才检查
// 修复:添加 optimizedDeltaX !== 0 检查,避免垂直滚动时被误拦截
if (
optimizedDeltaX !== 0 &&
this.scrollDirection !== undefined &&
this.scrollDirection !==
(optimizedDeltaX > 0
Expand Down
38 changes: 37 additions & 1 deletion packages/s2-core/src/facet/mobile/wheelEvent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,21 @@ const isMultiTouch = (evt: FederatedPointerEvent): boolean => {
return nativeEvent?.touches?.length >= 2;
};

/**
* 判断是否应该阻止默认滚动行为的回调函数类型
* @param deltaX 水平滚动距离
* @param deltaY 垂直滚动距离
* @param offsetX 触摸点 X 坐标
* @param offsetY 触摸点 Y 坐标
* @returns boolean - true 表示应该阻止默认行为
*/
export type ShouldPreventDefaultCallback = (
deltaX: number,
deltaY: number,
offsetX: number,
offsetY: number,
) => boolean;

/**
* 移动端滚动事件
* @see https://github.com/antvis/g-gesture/blob/next/src/event/wheel.ts
Expand All @@ -49,10 +64,16 @@ export class WheelEvent extends EE {

private raf: number;

constructor(canvas: Canvas) {
private shouldPreventDefault?: ShouldPreventDefaultCallback;

constructor(
canvas: Canvas,
shouldPreventDefault?: ShouldPreventDefaultCallback,
) {
super();
this.canvas = canvas;
this.panning = false;
this.shouldPreventDefault = shouldPreventDefault;

this.init();
}
Expand Down Expand Up @@ -102,12 +123,25 @@ export class WheelEvent extends EE {
}

if (this.panning) {
const nativeEvent = evt.nativeEvent;
const ms = now();
const deltaMS = ms - this.lastMoveMS;

const deltaX = this.preX - evt.x;
const deltaY = this.preY - evt.y;

// https://github.com/antvis/S2/issues/3249
// 根据回调判断是否阻止默认滚动行为
// 必须在事件链早期调用,否则浏览器的 passive 事件监听器会接管滚动
if (nativeEvent?.cancelable) {
const shouldPrevent =
this.shouldPreventDefault?.(deltaX, deltaY, evt.x, evt.y) ?? true;

if (shouldPrevent) {
(nativeEvent as Event).preventDefault?.();
}
}

this.speedX = deltaX / deltaMS;
this.speedY = deltaY / deltaMS;

Expand All @@ -121,6 +155,8 @@ export class WheelEvent extends EE {
y: evt.y,
deltaX,
deltaY,
// 传递原生事件用于移动端 preventDefault
nativeEvent,
} as unknown as FederatedWheelEvent);
}
};
Expand Down
Loading