Skip to content

Commit b4c4e81

Browse files
committed
feat(Camera): add pinch-to-zoom speed factor to increase zoom speed on pinch
performance(isTrackpadDetector): remove timeout to reset flag isTrackpadDetected. fix(isTrackpadDetector): added some accurate trackpad usage checks
1 parent 6fc3b55 commit b4c4e81

File tree

3 files changed

+79
-48
lines changed

3 files changed

+79
-48
lines changed

src/graphConfig.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,13 @@ export type TGraphConstants = {
144144
* @see https://w3c.github.io/uievents/#events-wheelevents - W3C UI Events Wheel Events specification
145145
*/
146146
MOUSE_WHEEL_BEHAVIOR: TMouseWheelBehavior;
147+
/**
148+
* Multiplier for trackpad pinch-to-zoom gesture speed.
149+
* Applied when zooming with trackpad using pinch gesture (Cmd/Ctrl + scroll).
150+
*
151+
* @default 1
152+
*/
153+
PINCH_ZOOM_SPEED: number;
147154
};
148155

149156
block: {
@@ -196,6 +203,7 @@ export const initGraphConstants: TGraphConstants = {
196203
AUTO_PAN_THRESHOLD: 50,
197204
AUTO_PAN_SPEED: 5,
198205
MOUSE_WHEEL_BEHAVIOR: "zoom",
206+
PINCH_ZOOM_SPEED: 1,
199207
},
200208
block: {
201209
WIDTH_MIN: 16 * 10,

src/services/camera/Camera.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -215,7 +215,7 @@ export class Camera extends EventedComponent<TCameraProps, TComponentState, TGra
215215
/**
216216
* Handles zoom behavior for both trackpad pinch and mouse wheel
217217
*/
218-
private handleWheelZoom(event: WheelEvent): void {
218+
private handleWheelZoom(event: WheelEvent, acceleration = 1): void {
219219
if (!event.deltaY) {
220220
return;
221221
}
@@ -230,7 +230,8 @@ export class Camera extends EventedComponent<TCameraProps, TComponentState, TGra
230230
* Therefore, we have to round the value of deltaY to 1 if it is less than or equal to 1.
231231
*/
232232
const pinchSpeed = Math.sign(event.deltaY) * clamp(Math.abs(event.deltaY), 1, 20);
233-
const dScale = this.context.constants.camera.STEP * this.context.constants.camera.SPEED * pinchSpeed;
233+
234+
const dScale = this.context.constants.camera.STEP * this.context.constants.camera.SPEED * pinchSpeed * acceleration;
234235

235236
const cameraScale = this.camera.getCameraScale();
236237

@@ -266,8 +267,11 @@ export class Camera extends EventedComponent<TCameraProps, TComponentState, TGra
266267
}
267268
}
268269

270+
// Calculate acceleration based on trackpad pinch-to-zoom gesture
271+
const isPinchToZoom = isTrackpad && isMetaKeyEvent(event);
272+
const acceleration = isPinchToZoom ? this.context.constants.camera.PINCH_ZOOM_SPEED : 1;
269273
// Default: zoom behavior (trackpad pinch or mouse wheel with "zoom" mode)
270-
this.handleWheelZoom(event);
274+
this.handleWheelZoom(event, acceleration);
271275
};
272276

273277
protected moveWithEdges(deltaX: number, deltaY: number) {

src/utils/functions/isTrackpadDetector.ts

Lines changed: 64 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,3 @@
1-
import { ESchedulerPriority } from "../../lib";
2-
import { debounce } from "../utils/schedule";
3-
41
// Time in milliseconds to keep trackpad detection state
52
const TRACKPAD_DETECTION_STATE_TIMEOUT = 60_000; // 1 minute
63

@@ -11,11 +8,11 @@ const TRACKPAD_DETECTION_STATE_TIMEOUT = 60_000; // 1 minute
118
* the input device type. The detection is based on several behavioral patterns:
129
*
1310
* - **Pinch-to-zoom gestures**: Trackpads generate wheel events with modifier keys (Ctrl/Meta)
14-
* and continuous (non-integer) delta values
11+
* and continuous (usually fractional) delta values.
1512
* - **Horizontal scrolling**: Trackpads naturally produce horizontal scroll events (deltaX),
16-
* while mice typically only scroll vertically
17-
* - **Continuous scrolling**: Trackpad scroll deltas are usually fractional values, while mouse
18-
* wheels produce discrete integer values
13+
* while mice typically only scroll vertically (unless Shift is pressed).
14+
* - **Continuous scrolling**: Trackpad scroll deltas are usually fractional or very small values,
15+
* while mouse wheels produce discrete larger integer values (typically 100 or 120).
1916
*
2017
* The detector maintains state across events to provide consistent results during a scroll session.
2118
* Once a trackpad is detected, the state persists for 60 seconds before resetting.
@@ -37,73 +34,95 @@ const TRACKPAD_DETECTION_STATE_TIMEOUT = 60_000; // 1 minute
3734
*/
3835
function isTrackpadDetector() {
3936
let isTrackpadDetected = false;
37+
let lastDetectionTime: number | null = null;
38+
let lastDpr = 0;
4039

4140
/**
42-
* Debounced function to reset trackpad detection state.
43-
* Uses scheduler-based timing instead of setTimeout for consistency with the rest of the library.
44-
*/
45-
const resetState = debounce(
46-
() => {
47-
isTrackpadDetected = false;
48-
},
49-
{
50-
priority: ESchedulerPriority.LOWEST,
51-
frameTimeout: TRACKPAD_DETECTION_STATE_TIMEOUT,
52-
}
53-
);
54-
55-
/**
56-
* Marks the current input device as trackpad and schedules state reset.
41+
* Marks the current input device as trackpad and records the detection time.
5742
* This ensures consistent detection during continuous scroll operations.
5843
*/
59-
const markAsTrackpad = (): void => {
44+
const markAsTrackpad = (dpr: number): void => {
6045
isTrackpadDetected = true;
61-
resetState();
46+
lastDetectionTime = performance.now();
47+
lastDpr = dpr;
6248
};
6349

6450
/**
6551
* Analyzes a wheel event to determine if it originated from a trackpad.
6652
*
6753
* @param e - The WheelEvent to analyze
6854
* @param dpr - Device pixel ratio for normalizing delta values. Defaults to window.devicePixelRatio.
69-
* This normalization accounts for browser zoom levels to improve detection accuracy.
55+
* This normalization accounts for browser zoom levels.
7056
* @returns `true` if the event is from a trackpad, `false` if from a mouse wheel
7157
*/
7258
return (e: WheelEvent, dpr: number = globalThis.devicePixelRatio || 1) => {
59+
const now = performance.now();
60+
61+
// Fast path: if we have valid cached detection state and DPR hasn't changed,
62+
// we already know it's a trackpad. We refresh the detection if we see more evidence below.
63+
if (isTrackpadDetected && lastDetectionTime !== null) {
64+
if (now - lastDetectionTime < TRACKPAD_DETECTION_STATE_TIMEOUT && lastDpr === dpr) {
65+
// We'll continue to check for new evidence to refresh the timeout
66+
} else {
67+
// State expired or DPR changed - need to re-evaluate
68+
isTrackpadDetected = false;
69+
lastDetectionTime = null;
70+
}
71+
}
72+
7373
const normalizedDeltaY = e.deltaY * dpr;
7474
const normalizedDeltaX = e.deltaX * dpr;
75-
const hasFractionalDelta = normalizedDeltaY && !Number.isInteger(normalizedDeltaY);
7675

77-
// Detection 1: Pinch-to-zoom gesture
76+
// Detection 1: Small or fractional deltas
77+
// Trackpads produce fractional values or very small integers.
78+
// Mouse produce large integers
79+
const hasFractionalDelta =
80+
!Number.isInteger(e.deltaY) ||
81+
!Number.isInteger(e.deltaX) ||
82+
!Number.isInteger(normalizedDeltaY) ||
83+
!Number.isInteger(normalizedDeltaX);
84+
85+
const isSmallDelta = Math.abs(e.deltaY) < 50 && Math.abs(e.deltaX) < 50;
86+
87+
// Detection 2: Pinch-to-zoom gesture
7888
// Trackpad pinch-to-zoom generates wheel events with ctrlKey or metaKey.
79-
// Combined with non-integer deltaY, this is a strong indicator of trackpad.
80-
const isPinchToZoomGesture = (e.ctrlKey || e.metaKey) && hasFractionalDelta;
81-
if (isPinchToZoomGesture) {
82-
markAsTrackpad();
89+
// Combined with small or fractional deltas, this is a very strong indicator of trackpad.
90+
// Note: Mouse wheel with Ctrl pressed usually has large delta (e.g., 100).
91+
if ((e.ctrlKey || e.metaKey) && (hasFractionalDelta || isSmallDelta)) {
92+
markAsTrackpad(dpr);
8393
return true;
8494
}
8595

86-
// Detection 2: Horizontal scroll (deltaX)
96+
// Detection 3: Horizontal scroll (deltaX)
8797
// Trackpad naturally produces horizontal scroll events.
88-
// Note: When Shift is pressed, browser swaps deltaX and deltaY for mouse wheel,
89-
// so we skip this check to avoid false positives.
90-
const hasHorizontalScroll = normalizedDeltaX !== 0;
91-
const isShiftPressed = e.shiftKey;
92-
if (hasHorizontalScroll && !isShiftPressed) {
93-
markAsTrackpad();
98+
// Note: When Shift is pressed, browsers swap deltaX and deltaY for mouse wheel,
99+
// so we skip this check to avoid false positives from mouse.
100+
if (normalizedDeltaX !== 0 && !e.shiftKey) {
101+
markAsTrackpad(dpr);
94102
return true;
95103
}
96104

97-
// Detection 3: Fractional deltaY (mouse produces integer values)
98-
// If we have non-integer deltaY without ctrl/meta keys, it's likely NOT trackpad
99-
// (could be browser zoom or other factors), so we explicitly return false.
105+
// Detection 4: Smooth scrolling
106+
// If we have non-integer values, it's almost certainly a trackpad or high-precision scroll.
100107
if (hasFractionalDelta) {
101-
return false;
108+
markAsTrackpad(dpr);
109+
return true;
110+
}
111+
112+
// Fallback: If we already detected a trackpad recently, stay in trackpad mode
113+
// unless we see obvious mouse-like behavior (large integer deltas).
114+
if (isTrackpadDetected && lastDetectionTime !== null) {
115+
// If it's still small delta, refresh the timestamp to keep trackpad state alive
116+
if (isSmallDelta) {
117+
lastDetectionTime = now;
118+
}
119+
return true;
102120
}
103121

104-
// Fallback: Return previously detected state
105-
// This helps maintain consistency across rapid scroll events.
106-
return isTrackpadDetected;
122+
// No trackpad detected
123+
isTrackpadDetected = false;
124+
lastDetectionTime = null;
125+
return false;
107126
};
108127
}
109128

0 commit comments

Comments
 (0)