Skip to content

Commit c769d55

Browse files
authored
feat(Camera): implement configurable mouse wheel behavior for zooming and scrolling (#173)
1 parent f66ee7b commit c769d55

File tree

6 files changed

+243
-61
lines changed

6 files changed

+243
-61
lines changed

docs/system/camera.md

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,55 @@ export type TCameraState = {
4747
- `getCameraScale()` – scale value.
4848
- `getCameraBlockScaleLevel(scale?)` – qualitative zoom tiers for switching rendering modes.
4949

50+
### Mouse wheel behavior
51+
52+
Mouse wheel interactions can be configured to either zoom the graph or scroll it.
53+
54+
**Configuration:**
55+
```ts
56+
const graph = new Graph(canvas, {
57+
constants: {
58+
camera: {
59+
MOUSE_WHEEL_BEHAVIOR: "zoom" // "zoom" | "scroll" (default: "zoom")
60+
}
61+
}
62+
});
63+
```
64+
65+
**Behavior modes:**
66+
- `"zoom"` (default) – Mouse wheel zooms the graph in/out at the cursor position.
67+
- `"scroll"` – Mouse wheel scrolls the graph vertically by default, or horizontally when Shift key is pressed.
68+
69+
**Scroll direction:**
70+
- Default: Vertical scrolling (up/down along Y axis)
71+
- With Shift: Horizontal scrolling (left/right along X axis)
72+
73+
**Important notes:**
74+
- This configuration only affects mouse wheel behavior.
75+
- Scroll direction switching with Shift is an environment-dependent behavior according to [W3C UI Events specification](https://w3c.github.io/uievents/#events-wheelevents).
76+
- Different browsers and operating systems may handle Shift+wheel differently.
77+
- Trackpad gestures remain unchanged and use their native behavior:
78+
- Pinch to zoom
79+
- Two-finger swipe to scroll in any direction
80+
- Settings can be updated at runtime using `graph.setConstants()`.
81+
82+
**Example:**
83+
```ts
84+
// Configure mouse wheel to scroll instead of zooming
85+
graph.setConstants({
86+
camera: {
87+
MOUSE_WHEEL_BEHAVIOR: "scroll"
88+
}
89+
});
90+
91+
// Switch back to zoom mode
92+
graph.setConstants({
93+
camera: {
94+
MOUSE_WHEEL_BEHAVIOR: "zoom"
95+
}
96+
});
97+
```
98+
5099
### Auto-panning
51100

52101
Auto-panning automatically moves the camera when the cursor is near viewport edges during drag operations. This feature is built into the camera system and activates automatically for block dragging, area selection, connection creation, and block duplication.

src/graphConfig.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ export type TCanvasColors = {
4848
border: string;
4949
};
5050

51+
export type TMouseWheelBehavior = "zoom" | "scroll";
52+
5153
export const initGraphColors: TGraphColors = {
5254
anchor: {
5355
background: "#4a4a4a",
@@ -119,6 +121,29 @@ export type TGraphConstants = {
119121
* @default 10
120122
*/
121123
AUTO_PAN_SPEED: number;
124+
/**
125+
* Controls the behavior of mouse wheel events.
126+
*
127+
* - **"zoom"**: Mouse wheel will zoom in/out the graph
128+
* - **"scroll"**: Mouse wheel will scroll the graph vertically by default, or horizontally when Shift is pressed
129+
*
130+
* @remarks
131+
* **Mouse wheel scrolling behavior:**
132+
* - Default scroll direction is vertical (up/down)
133+
* - Holding Shift key switches to horizontal scrolling (left/right)
134+
* - This is an environment-dependent behavior as per W3C UI Events specification
135+
* - Different browsers and operating systems may handle Shift+wheel differently
136+
*
137+
* **Trackpad behavior:**
138+
* - This setting only affects mouse wheel behavior
139+
* - Trackpad gestures remain unchanged and use their native behavior:
140+
* - Pinch to zoom
141+
* - Two-finger swipe to scroll in any direction
142+
*
143+
* @default "zoom"
144+
* @see https://w3c.github.io/uievents/#events-wheelevents - W3C UI Events Wheel Events specification
145+
*/
146+
MOUSE_WHEEL_BEHAVIOR: TMouseWheelBehavior;
122147
};
123148

124149
block: {
@@ -169,6 +194,7 @@ export const initGraphConstants: TGraphConstants = {
169194
STEP: 0.008,
170195
AUTO_PAN_THRESHOLD: 50,
171196
AUTO_PAN_SPEED: 5,
197+
MOUSE_WHEEL_BEHAVIOR: "zoom",
172198
},
173199
block: {
174200
WIDTH_MIN: 16 * 10,

src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ export { Block as CanvasBlock, type TBlock } from "./components/canvas/blocks/Bl
33
export { GraphComponent } from "./components/canvas/GraphComponent";
44
export * from "./components/canvas/connections";
55
export * from "./graph";
6-
export type { TGraphColors, TGraphConstants } from "./graphConfig";
6+
export type { TGraphColors, TGraphConstants, TMouseWheelBehavior } from "./graphConfig";
77
export { type UnwrapGraphEventsDetail, type SelectionEvent } from "./graphEvents";
88
export * from "./plugins";
99
export { ECameraScaleLevel } from "./services/camera/CameraService";

src/services/camera/Camera.ts

Lines changed: 47 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -200,30 +200,28 @@ export class Camera extends EventedComponent<TCameraProps, TComponentState, TGra
200200
this.lastDragEvent = undefined;
201201
}
202202

203-
private handleWheelEvent = (event: WheelEvent) => {
204-
if (!this.context.graph.rootStore.settings.getConfigFlag("canZoomCamera")) {
205-
return;
206-
}
207-
208-
event.stopPropagation();
209-
event.preventDefault();
210-
211-
const isMoveEvent = isTrackpadWheelEvent(event) && !isMetaKeyEvent(event);
212-
213-
if (isMoveEvent) {
214-
const windows = isWindows();
203+
/**
204+
* Handles trackpad swipe gestures for camera movement
205+
*/
206+
private handleTrackpadMove(event: WheelEvent): void {
207+
const windows = isWindows();
208+
209+
this.moveWithEdges(
210+
windows && event.shiftKey ? -event.deltaY : -event.deltaX,
211+
windows && event.shiftKey ? -event.deltaX : -event.deltaY
212+
);
213+
}
215214

216-
this.moveWithEdges(
217-
windows && event.shiftKey ? -event.deltaY : -event.deltaX,
218-
windows && event.shiftKey ? -event.deltaX : -event.deltaY
219-
);
215+
/**
216+
* Handles zoom behavior for both trackpad pinch and mouse wheel
217+
*/
218+
private handleWheelZoom(event: WheelEvent): void {
219+
if (!event.deltaY) {
220220
return;
221221
}
222222

223223
const xy = getXY(this.context.canvas, event);
224224

225-
if (!event.deltaY) return;
226-
227225
/**
228226
* Speed of wheel/trackpad pinch
229227
*
@@ -239,6 +237,37 @@ export class Camera extends EventedComponent<TCameraProps, TComponentState, TGra
239237
// Smooth scale. The closer you get, the higher the speed
240238
const smoothDScale = dScale * cameraScale;
241239
this.camera.zoom(xy[0], xy[1], cameraScale - smoothDScale);
240+
}
241+
242+
private handleWheelEvent = (event: WheelEvent) => {
243+
if (!this.context.graph.rootStore.settings.getConfigFlag("canZoomCamera")) {
244+
return;
245+
}
246+
247+
event.stopPropagation();
248+
event.preventDefault();
249+
250+
const isTrackpad = isTrackpadWheelEvent(event);
251+
const isTrackpadMove = isTrackpad && !isMetaKeyEvent(event);
252+
253+
// Trackpad swipe gesture - always moves camera
254+
if (isTrackpadMove) {
255+
this.handleTrackpadMove(event);
256+
return;
257+
}
258+
259+
// Mouse wheel behavior - check configuration
260+
if (!isTrackpad) {
261+
const mouseWheelBehavior = this.context.constants.camera.MOUSE_WHEEL_BEHAVIOR;
262+
263+
if (mouseWheelBehavior === "scroll") {
264+
this.handleTrackpadMove(event);
265+
return;
266+
}
267+
}
268+
269+
// Default: zoom behavior (trackpad pinch or mouse wheel with "zoom" mode)
270+
this.handleWheelZoom(event);
242271
};
243272

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

src/utils/functions/index.ts

Lines changed: 1 addition & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -173,48 +173,6 @@ export function isWindows() {
173173
return navigator.appVersion.indexOf("Win") !== -1;
174174
}
175175

176-
/**
177-
* Detects if the event is from a trackpad.
178-
* Way to detect is a bit of a hack, but it's the easiest way to detect a mouse.
179-
*
180-
* The deltaY in the trackpad scroll USUALLY is not zero.
181-
* The deltaX in the trackpad scroll USUALLY is not zero.
182-
* The deltaY in the mouse scroll event USUALLY is a float number.
183-
*
184-
* ISSUE: When user use the browser zoom, deltaY is a float number.
185-
* It is may be cause of the false-negative detection.
186-
* For this case deltaY have to be normalized by devicePixelRatio.
187-
*
188-
* @returns true if the event is from a trackpad, false otherwise.
189-
*/
190-
function isTrackpadDetector() {
191-
let isTrackpadDetected = false;
192-
let cleanStateTimer = setTimeout(() => {}, 0);
193-
194-
return (e: WheelEvent, dpr: number = globalThis.devicePixelRatio || 1) => {
195-
const normalizedDeltaY = e.deltaY * dpr;
196-
const normalizedDeltaX = e.deltaX * dpr;
197-
// deltaX in the trackpad scroll usually is not zero.
198-
if (normalizedDeltaX) {
199-
isTrackpadDetected = true;
200-
clearTimeout(cleanStateTimer);
201-
cleanStateTimer = setTimeout(() => {
202-
isTrackpadDetected = false;
203-
}, 1000 * 60);
204-
205-
return true;
206-
}
207-
208-
if (normalizedDeltaY && !Number.isInteger(normalizedDeltaY)) {
209-
return false;
210-
}
211-
212-
return isTrackpadDetected;
213-
};
214-
}
215-
216-
export const isTrackpadWheelEvent = isTrackpadDetector();
217-
218176
/**
219177
* Calculates a "nice" number approximately equal to the range.
220178
* Useful for determining tick spacing on axes or rulers.
@@ -273,3 +231,4 @@ export function computeCssVariable(name: string) {
273231

274232
// Re-export scheduler utilities
275233
export { schedule, debounce, throttle } from "../utils/schedule";
234+
export { isTrackpadWheelEvent } from "./isTrackpadDetector";
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
// Time in milliseconds to keep trackpad detection state
2+
const TRACKPAD_DETECTION_STATE_TIMEOUT = 60_000; // 1 minute
3+
4+
/**
5+
* Creates a trackpad detection function that distinguishes between trackpad and mouse wheel events.
6+
*
7+
* This factory function returns a detector that analyzes WheelEvent characteristics to determine
8+
* the input device type. The detection is based on several behavioral patterns:
9+
*
10+
* - **Pinch-to-zoom gestures**: Trackpads generate wheel events with modifier keys (Ctrl/Meta)
11+
* and continuous (non-integer) delta values
12+
* - **Horizontal scrolling**: Trackpads naturally produce horizontal scroll events (deltaX),
13+
* while mice typically only scroll vertically
14+
* - **Continuous scrolling**: Trackpad scroll deltas are usually fractional values, while mouse
15+
* wheels produce discrete integer values
16+
*
17+
* The detector maintains state across events to provide consistent results during a scroll session.
18+
* Once a trackpad is detected, the state persists for 60 seconds before resetting.
19+
*
20+
* @returns A detection function that accepts WheelEvent and optional devicePixelRatio
21+
*
22+
* @example
23+
* ```typescript
24+
* const isTrackpad = isTrackpadDetector();
25+
*
26+
* element.addEventListener('wheel', (e) => {
27+
* if (isTrackpad(e)) {
28+
* console.log('Trackpad scroll detected');
29+
* } else {
30+
* console.log('Mouse wheel detected');
31+
* }
32+
* });
33+
* ```
34+
*/
35+
function isTrackpadDetector() {
36+
let isTrackpadDetected = false;
37+
let cleanStateTimer: number | null = null;
38+
39+
/**
40+
* Marks the current input device as trackpad and resets the state timeout.
41+
* This ensures consistent detection during continuous scroll operations.
42+
*/
43+
const markAsTrackpad = (): void => {
44+
isTrackpadDetected = true;
45+
clearTimeout(cleanStateTimer);
46+
cleanStateTimer = setTimeout(() => {
47+
isTrackpadDetected = false;
48+
}, TRACKPAD_DETECTION_STATE_TIMEOUT) as unknown as number;
49+
};
50+
51+
/**
52+
* Analyzes a wheel event to determine if it originated from a trackpad.
53+
*
54+
* @param e - The WheelEvent to analyze
55+
* @param dpr - Device pixel ratio for normalizing delta values. Defaults to window.devicePixelRatio.
56+
* This normalization accounts for browser zoom levels to improve detection accuracy.
57+
* @returns `true` if the event is from a trackpad, `false` if from a mouse wheel
58+
*/
59+
return (e: WheelEvent, dpr: number = globalThis.devicePixelRatio || 1) => {
60+
const normalizedDeltaY = e.deltaY * dpr;
61+
const normalizedDeltaX = e.deltaX * dpr;
62+
const hasFractionalDelta = normalizedDeltaY && !Number.isInteger(normalizedDeltaY);
63+
64+
// Detection 1: Pinch-to-zoom gesture
65+
// Trackpad pinch-to-zoom generates wheel events with ctrlKey or metaKey.
66+
// Combined with non-integer deltaY, this is a strong indicator of trackpad.
67+
const isPinchToZoomGesture = (e.ctrlKey || e.metaKey) && hasFractionalDelta;
68+
if (isPinchToZoomGesture) {
69+
markAsTrackpad();
70+
return true;
71+
}
72+
73+
// Detection 2: Horizontal scroll (deltaX)
74+
// Trackpad naturally produces horizontal scroll events.
75+
// Note: When Shift is pressed, browser swaps deltaX and deltaY for mouse wheel,
76+
// so we skip this check to avoid false positives.
77+
const hasHorizontalScroll = normalizedDeltaX !== 0;
78+
const isShiftPressed = e.shiftKey;
79+
if (hasHorizontalScroll && !isShiftPressed) {
80+
markAsTrackpad();
81+
return true;
82+
}
83+
84+
// Detection 3: Fractional deltaY (mouse produces integer values)
85+
// If we have non-integer deltaY without ctrl/meta keys, it's likely NOT trackpad
86+
// (could be browser zoom or other factors), so we explicitly return false.
87+
if (hasFractionalDelta) {
88+
return false;
89+
}
90+
91+
// Fallback: Return previously detected state
92+
// This helps maintain consistency across rapid scroll events.
93+
return isTrackpadDetected;
94+
};
95+
}
96+
97+
/**
98+
* Global trackpad detector instance for wheel events.
99+
*
100+
* Use this pre-configured detector to check if a wheel event originated from a trackpad
101+
* rather than a traditional mouse wheel. The detector maintains state across calls to provide
102+
* consistent results throughout a scroll session.
103+
*
104+
* @example
105+
* ```typescript
106+
* import { isTrackpadWheelEvent } from './utils/functions';
107+
*
108+
* canvas.addEventListener('wheel', (event) => {
109+
* if (isTrackpadWheelEvent(event)) {
110+
* // Handle smooth trackpad scrolling
111+
* applyMomentumScrolling(event);
112+
* } else {
113+
* // Handle discrete mouse wheel steps
114+
* applySteppedScrolling(event);
115+
* }
116+
* });
117+
* ```
118+
*/
119+
export const isTrackpadWheelEvent = isTrackpadDetector();

0 commit comments

Comments
 (0)