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
19 changes: 18 additions & 1 deletion docs/system/camera.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,24 @@ const graph = new Graph(canvas, {
- Two-finger swipe to scroll in any direction
- Settings can be updated at runtime using `graph.setConstants()`.

**Example:**
### Custom wheel device classification

The camera distinguishes **trackpad-like** input (two-finger pan, pinch-zoom) from **mouse wheel** when routing `wheel` events. This is configured on **graph settings** (`resolveWheelDevice`). By default it uses `defaultResolveWheelDevice`, which wraps `isTrackpadWheelEvent`. Replace it with your own `(event: WheelEvent) => EWheelDeviceKind` if needed:

```ts
import { defaultResolveWheelDevice, EWheelDeviceKind } from "@gravity-ui/graph";

graph.updateSettings({
resolveWheelDevice: (event) => {
// Example: always treat as mouse wheel
return EWheelDeviceKind.Mouse;
// Or extend the default:
// return defaultResolveWheelDevice(event);
},
});
```

**Example (MOUSE_WHEEL_BEHAVIOR):**
```ts
// Configure mouse wheel to scroll instead of zooming
graph.setConstants({
Expand Down
15 changes: 15 additions & 0 deletions e2e/page-objects/GraphCameraComponentObject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,21 @@ export class GraphCameraComponentObject {
});
}

/**
* Forces `resolveWheelDevice` on graph settings for e2e.
* Simulates a wheel device kind (`mouse` | `trackpad`) in the page; it does not assert
* real browser/vendor wheel payloads. Playwright cannot serialize functions from Node.
*/
async setResolveWheelDeviceOverride(kind: "mouse" | "trackpad"): Promise<void> {
await this.page.evaluate((k) => {
const { EWheelDeviceKind } = window.GraphModule;
window.graph.updateSettings({
resolveWheelDevice: () =>
k === "mouse" ? EWheelDeviceKind.Mouse : EWheelDeviceKind.Trackpad,
});
}, kind);
}

/**
* Emulate zoom with mouse wheel
* @param deltaY - Positive = zoom out, Negative = zoom in
Expand Down
90 changes: 90 additions & 0 deletions e2e/tests/camera/mouse-wheel-behavior-by-device.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { test, expect } from "@playwright/test";
import { GraphPageObject } from "../../page-objects/GraphPageObject";

const BLOCK = {
id: "block-1",
is: "Block" as const,
x: 100,
y: 100,
width: 200,
height: 100,
name: "Block 1",
anchors: [],
selected: false,
};

/**
* Verifies `MOUSE_WHEEL_BEHAVIOR` (zoom vs scroll) together with the outcome of
* `resolveWheelDevice` (trackpad vs mouse routing).
*
* We **simulate** device kind in the page via `setResolveWheelDeviceOverride` — this does
* **not** validate how real browsers or vendors emit wheel events; it only checks that
* camera routing matches the resolved device kind and `MOUSE_WHEEL_BEHAVIOR`.
*/
test.describe("MOUSE_WHEEL_BEHAVIOR for simulated wheel device kinds", () => {
let graphPO: GraphPageObject;

test.beforeEach(async ({ page }) => {
graphPO = new GraphPageObject(page);
await graphPO.initialize({
blocks: [BLOCK],
connections: [],
settings: {
canDragCamera: true,
canZoomCamera: true,
},
});
});

test("simulated mouse + MOUSE_WHEEL_BEHAVIOR zoom: vertical wheel changes scale", async () => {
const camera = graphPO.getCamera();
await camera.zoomToCenter();
await graphPO.waitForFrames(3);

// Room below max scale so zoom-in can increase scale (same pattern as camera-control.spec).
await camera.emulateZoom(300);
await graphPO.waitForFrames(2);

await camera.setResolveWheelDeviceOverride("mouse");

const before = await camera.getState();
await camera.emulateZoom(-100);
const after = await camera.getState();

expect(after.scale).toBeGreaterThan(before.scale);
});

test("simulated trackpad: vertical wheel pans (scale unchanged)", async () => {
const camera = graphPO.getCamera();
await camera.zoomToCenter();
await graphPO.waitForFrames(3);

await camera.setResolveWheelDeviceOverride("trackpad");

const before = await camera.getState();
await camera.emulateZoom(-100);
const after = await camera.getState();

expect(after.scale).toBeCloseTo(before.scale, 10);
});

test("simulated mouse + MOUSE_WHEEL_BEHAVIOR scroll: vertical wheel pans (scale unchanged)", async () => {
const camera = graphPO.getCamera();
await camera.zoomToCenter();
await graphPO.waitForFrames(3);

await graphPO.page.evaluate(() => {
window.graph.setConstants({
camera: { MOUSE_WHEEL_BEHAVIOR: "scroll" },
});
});

await camera.setResolveWheelDeviceOverride("mouse");

const before = await camera.getState();
await camera.emulateZoom(-100);
const after = await camera.getState();

expect(after.scale).toBeCloseTo(before.scale, 10);
});
});
3 changes: 3 additions & 0 deletions src/graphConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ import { GraphComponent } from "./components/canvas/GraphComponent";
import { Block } from "./components/canvas/blocks/Block";
import { ESelectionStrategy } from "./services/selection";

export type { TResolveWheelDevice } from "./utils/functions/isTrackpadDetector";
export { defaultResolveWheelDevice, EWheelDeviceKind } from "./utils/functions/isTrackpadDetector";

export type TGraphColors = {
canvas?: Partial<TCanvasColors>;
block?: Partial<TBlockColors>;
Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ export { GraphComponent } from "./components/canvas/GraphComponent";
export * from "./components/canvas/connections";
export * from "./graph";
export type { TGraphColors, TGraphConstants, TMouseWheelBehavior } from "./graphConfig";
export type { TResolveWheelDevice } from "./utils/functions/isTrackpadDetector";
export { defaultResolveWheelDevice, EWheelDeviceKind } from "./utils/functions/isTrackpadDetector";
export { type UnwrapGraphEventsDetail, type SelectionEvent } from "./graphEvents";
export * from "./plugins";
export { ECameraScaleLevel } from "./services/camera/CameraService";
Expand Down
6 changes: 4 additions & 2 deletions src/services/camera/Camera.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@ import { TGraphLayerContext } from "../../components/canvas/layers/graphLayer/Gr
import { Component, ESchedulerPriority } from "../../lib";
import { TComponentProps, TComponentState } from "../../lib/Component";
import { ComponentDescriptor } from "../../lib/CoreComponent";
import { getXY, isMetaKeyEvent, isTrackpadWheelEvent } from "../../utils/functions";
import { getXY, isMetaKeyEvent } from "../../utils/functions";
import { clamp } from "../../utils/functions/clamp";
import { dragListener } from "../../utils/functions/dragListener";
import { EWheelDeviceKind } from "../../utils/functions/isTrackpadDetector";
import { EVENTS } from "../../utils/types/events";
import { schedule } from "../../utils/utils/schedule";

Expand Down Expand Up @@ -248,7 +249,8 @@ export class Camera extends EventedComponent<TCameraProps, TComponentState, TGra
event.stopPropagation();
event.preventDefault();

const isTrackpad = isTrackpadWheelEvent(event);
const wheelDevice = this.context.graph.rootStore.settings.wheelDeviceFromEvent(event);
const isTrackpad = wheelDevice === EWheelDeviceKind.Trackpad;
const isTrackpadMove = isTrackpad && !isMetaKeyEvent(event);

// Trackpad swipe gesture - always moves camera
Expand Down
17 changes: 17 additions & 0 deletions src/store/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import cloneDeep from "lodash/cloneDeep";
import type { Block, TBlock } from "../components/canvas/blocks/Block";
import { BlockConnection } from "../components/canvas/connections/BlockConnection";
import { Component } from "../lib";
import type { EWheelDeviceKind, TResolveWheelDevice } from "../utils/functions/isTrackpadDetector";
import { defaultResolveWheelDevice } from "../utils/functions/isTrackpadDetector";

import { TConnection } from "./connection/ConnectionState";

Expand Down Expand Up @@ -61,6 +63,13 @@ export type TGraphSettingsConfig<Block extends TBlock = TBlock, Connection exten
* Default: false
*/
emulateMouseEventsOnCameraChange?: boolean;
/**
* Classifies wheel input so the camera can treat trackpad (pan, pinch-zoom) vs mouse wheel
* (see graph constants `MOUSE_WHEEL_BEHAVIOR`) differently.
*
* @default {@link defaultResolveWheelDevice} — built-in heuristics from `isTrackpadWheelEvent`.
*/
resolveWheelDevice: TResolveWheelDevice;
};

export const DefaultSettings: TGraphSettingsConfig = {
Expand All @@ -78,6 +87,7 @@ export const DefaultSettings: TGraphSettingsConfig = {
connectivityComponentOnClickRaise: true,
showConnectionLabels: false,
blockComponents: {},
resolveWheelDevice: defaultResolveWheelDevice,
};

export class GraphEditorSettings {
Expand Down Expand Up @@ -111,6 +121,13 @@ export class GraphEditorSettings {
return this.$settings.value[flagPath];
}

/**
* Resolves wheel input using {@link TGraphSettingsConfig.resolveWheelDevice} (typed; prefer over getConfigFlag).
*/
public wheelDeviceFromEvent(event: WheelEvent): EWheelDeviceKind {
return this.$settings.value.resolveWheelDevice(event);
}

public $connectionsSettings = computed(() => {
return {
useBezierConnections: this.$settings.value.useBezierConnections,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,37 +1,70 @@
import React, { useLayoutEffect } from "react";
import React, { useEffect, useLayoutEffect, useMemo, useState } from "react";

import type { Meta, StoryFn } from "@storybook/react-webpack5";
import { Flex, Text, ThemeProvider } from "@gravity-ui/uikit";
import type { Meta, StoryObj } from "@storybook/react-webpack5";

import { TBlock } from "../../../components/canvas/blocks/Block";
import { Graph, GraphState } from "../../../graph";
import type { TMouseWheelBehavior } from "../../../graphConfig";
import { EWheelDeviceKind, defaultResolveWheelDevice } from "../../../graphConfig";
import { GraphBlock, GraphCanvas, HookGraphParams, useGraph, useGraphEvent } from "../../../react-components";
import { useFn } from "../../../react-components/utils/hooks/useFn";
import { ECanDrag } from "../../../store/settings";

const config: HookGraphParams = {
viewConfiguration: {
constants: {
camera: {
MOUSE_WHEEL_BEHAVIOR: "scroll",
},
},
},
settings: {
canDragCamera: true,
canZoomCamera: true,
canDuplicateBlocks: false,
canDrag: ECanDrag.ALL,
canCreateNewConnections: true,
showConnectionArrows: false,
scaleFontSize: 1,
useBezierConnections: true,
useBlocksAnchors: true,
showConnectionLabels: false,
},
import "@gravity-ui/uikit/styles/styles.css";

const GRAPH_SETTINGS: NonNullable<HookGraphParams["settings"]> = {
canDragCamera: true,
canZoomCamera: true,
canDuplicateBlocks: false,
canDrag: ECanDrag.ALL,
canCreateNewConnections: true,
showConnectionArrows: false,
scaleFontSize: 1,
useBezierConnections: true,
useBlocksAnchors: true,
showConnectionLabels: false,
};

const DEVICE_LABEL: Record<EWheelDeviceKind, string> = {
[EWheelDeviceKind.Mouse]: "Mouse wheel",
[EWheelDeviceKind.Trackpad]: "Trackpad",
};

type MouseWheelBehaviorStoryProps = {
/** Camera constant: vertical wheel when input is classified as mouse wheel. */
mouseWheelBehavior: TMouseWheelBehavior;
};

function GraphWithMouseWheelBehaviorScroll() {
const { graph, setEntities, start } = useGraph(config);
function GraphWithMouseWheelBehaviorScroll({ mouseWheelBehavior }: MouseWheelBehaviorStoryProps) {
const [resolvedWheelDevice, setResolvedWheelDevice] = useState<EWheelDeviceKind | null>(null);

const graphParams = useMemo<HookGraphParams>(
() => ({
viewConfiguration: {
constants: {
camera: {
MOUSE_WHEEL_BEHAVIOR: mouseWheelBehavior,
},
},
},
settings: {
...GRAPH_SETTINGS,
resolveWheelDevice: (event: WheelEvent) => {
const kind = defaultResolveWheelDevice(event);
setResolvedWheelDevice(kind);
return kind;
},
},
}),
[mouseWheelBehavior, setResolvedWheelDevice]
);

const { graph, setEntities, start } = useGraph(graphParams);

useEffect(() => {
setResolvedWheelDevice(null);
}, [mouseWheelBehavior]);

useGraphEvent(graph, "state-change", ({ state }) => {
if (state === GraphState.ATTACHED) {
Expand Down Expand Up @@ -75,22 +108,69 @@ function GraphWithMouseWheelBehaviorScroll() {
});
}, [setEntities]);

const renderBlockFn = useFn((graph: Graph, block: TBlock) => {
const renderBlockFn = useFn((g: Graph, block: TBlock) => {
return (
<GraphBlock graph={graph} block={block}>
<GraphBlock graph={g} block={block}>
{block.id.toLocaleString()}
</GraphBlock>
);
});

return <GraphCanvas graph={graph} renderBlock={renderBlockFn} />;
return (
<ThemeProvider theme={"light"}>
<Flex direction={"column"} style={{ height: "100vh" }}>
<Flex
direction={"column"}
gap={2}
style={{
flexShrink: 0,
padding: "12px 16px",
borderBottom: "1px solid var(--g-color-line-generic)",
background: "var(--g-color-base-background)",
}}
>
<Text variant={"body-2"}>
<strong>MOUSE_WHEEL_BEHAVIOR</strong> (constants): <strong>{mouseWheelBehavior}</strong> — change via
Storybook Controls
</Text>
<Text variant={"body-2"} color={"secondary"}>
<strong>Resolved wheel device</strong> (from <code>resolveWheelDevice</code> / heuristics):{" "}
{resolvedWheelDevice === null ? (
"scroll over the canvas with a mouse or trackpad"
) : (
<strong>{DEVICE_LABEL[resolvedWheelDevice]}</strong>
)}
</Text>
</Flex>
<div style={{ flex: 1, minHeight: 0, position: "relative" }}>
<GraphCanvas graph={graph} renderBlock={renderBlockFn} />
</div>
</Flex>
</ThemeProvider>
);
}

export const Default: StoryFn = () => <GraphWithMouseWheelBehaviorScroll />;

const meta: Meta = {
const meta: Meta<typeof GraphWithMouseWheelBehaviorScroll> = {
title: "Examples/MouseWheelBehaviorScroll",
component: GraphWithMouseWheelBehaviorScroll,
parameters: {
layout: "fullscreen",
},
argTypes: {
mouseWheelBehavior: {
control: "select",
options: ["scroll", "zoom"],
description:
"Camera constant MOUSE_WHEEL_BEHAVIOR: how vertical wheel behaves when input is classified as mouse wheel (not trackpad).",
},
},
args: {
mouseWheelBehavior: "scroll",
},
};

export default meta;

type Story = StoryObj<typeof GraphWithMouseWheelBehaviorScroll>;

export const Default: Story = {};
Loading
Loading