From 27fc06fe0721908c371ad2f221ed4088843501b6 Mon Sep 17 00:00:00 2001 From: draedful Date: Fri, 20 Mar 2026 16:09:13 +0300 Subject: [PATCH 1/2] fix(Camera): fix device detector. add setting to override device detector --- docs/system/camera.md | 19 +- .../GraphCameraComponentObject.ts | 15 + .../mouse-wheel-behavior-by-device.spec.ts | 90 ++++++ src/graphConfig.ts | 3 + src/index.ts | 2 + src/services/camera/Camera.ts | 6 +- src/store/settings.ts | 17 ++ src/utils/functions/index.ts | 2 +- src/utils/functions/isTrackpadDetector.ts | 281 +++++++++++++----- 9 files changed, 353 insertions(+), 82 deletions(-) create mode 100644 e2e/tests/camera/mouse-wheel-behavior-by-device.spec.ts diff --git a/docs/system/camera.md b/docs/system/camera.md index 33af7f67..4a3c0451 100644 --- a/docs/system/camera.md +++ b/docs/system/camera.md @@ -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({ diff --git a/e2e/page-objects/GraphCameraComponentObject.ts b/e2e/page-objects/GraphCameraComponentObject.ts index f2be711f..7116d023 100644 --- a/e2e/page-objects/GraphCameraComponentObject.ts +++ b/e2e/page-objects/GraphCameraComponentObject.ts @@ -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 { + 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 diff --git a/e2e/tests/camera/mouse-wheel-behavior-by-device.spec.ts b/e2e/tests/camera/mouse-wheel-behavior-by-device.spec.ts new file mode 100644 index 00000000..085a61fa --- /dev/null +++ b/e2e/tests/camera/mouse-wheel-behavior-by-device.spec.ts @@ -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); + }); +}); diff --git a/src/graphConfig.ts b/src/graphConfig.ts index de0cfbf0..86b47c5d 100644 --- a/src/graphConfig.ts +++ b/src/graphConfig.ts @@ -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; block?: Partial; diff --git a/src/index.ts b/src/index.ts index d94979f0..63efb114 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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"; diff --git a/src/services/camera/Camera.ts b/src/services/camera/Camera.ts index 1aa91c53..69718b07 100644 --- a/src/services/camera/Camera.ts +++ b/src/services/camera/Camera.ts @@ -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"; @@ -248,7 +249,8 @@ export class Camera extends EventedComponent { return { useBezierConnections: this.$settings.value.useBezierConnections, diff --git a/src/utils/functions/index.ts b/src/utils/functions/index.ts index 6f8f61d8..278850a7 100644 --- a/src/utils/functions/index.ts +++ b/src/utils/functions/index.ts @@ -244,7 +244,7 @@ export function computeCssVariable(name: string) { // Re-export scheduler utilities export { schedule, debounce, throttle } from "../utils/schedule"; -export { isTrackpadWheelEvent } from "./isTrackpadDetector"; +export { EWheelDeviceKind, defaultResolveWheelDevice, isTrackpadWheelEvent } from "./isTrackpadDetector"; // Re-export vector utilities export { vectorDistance, vectorDistanceSquared } from "./vector"; diff --git a/src/utils/functions/isTrackpadDetector.ts b/src/utils/functions/isTrackpadDetector.ts index 6094898c..6373624c 100644 --- a/src/utils/functions/isTrackpadDetector.ts +++ b/src/utils/functions/isTrackpadDetector.ts @@ -1,21 +1,158 @@ +/** Wheel input class for camera routing (pan vs zoom/scroll). */ +export enum EWheelDeviceKind { + Trackpad = "trackpad", + Mouse = "mouse", +} + +/** + * Classifies a wheel event as trackpad-like (two-finger / pinch semantics) or mouse wheel. + * Configured as `resolveWheelDevice` on graph settings (`TGraphSettingsConfig`). + */ +export type TResolveWheelDevice = (event: WheelEvent) => EWheelDeviceKind; + // Time in milliseconds to keep trackpad detection state const TRACKPAD_DETECTION_STATE_TIMEOUT = 60_000; // 1 minute +/** Events closer than this are treated as a continuous stream (typical trackpad / inertia). */ +const TRACKPAD_HIGH_FREQUENCY_MS = 38; + +/** Minimum absolute delta on both axes to count as diagonal scroll (trackpad-like). */ +const TRACKPAD_DIAGONAL_MIN_ABS = 2; + +/** Ignore sub-pixel horizontal noise (deltaX=1) for horizontal-only trackpad detection. */ +const MIN_HORIZONTAL_SCROLL_ABS = 2; + +const SMALL_DELTA_THRESHOLD = 50; + +/** If |deltaX| is below this, treat scroll as vertical-only (mouse wheel reports deltaY only). */ +const VERTICAL_ONLY_DELTA_X_EPSILON = 0.5; + +/** + * Wider threshold for the rapid-stream rule only: some mice/drivers emit deltaX=1 on vertical scroll; + * real trackpad two-finger scroll usually has |deltaX| >= 2 on many events. + */ +const VERTICAL_ONLY_FOR_RAPID_STREAM_EPSILON = 1.5; + +function isVerticalOnlyWheelEvent(e: WheelEvent): boolean { + return Math.abs(e.deltaX) < VERTICAL_ONLY_DELTA_X_EPSILON; +} + +function isVerticalOnlyForRapidStreamRule(e: WheelEvent): boolean { + return Math.abs(e.deltaX) < VERTICAL_ONLY_FOR_RAPID_STREAM_EPSILON; +} + +/** One axis dominates (large step), the other ~0 — typical mouse wheel (vertical or horizontal). */ +function isDominantAxisLargeWheel(e: WheelEvent): boolean { + const ax = Math.abs(e.deltaX); + const ay = Math.abs(e.deltaY); + return (ax >= SMALL_DELTA_THRESHOLD && ay < 0.5) || (ay >= SMALL_DELTA_THRESHOLD && ax < 0.5); +} + +function hasFractionalWheelDelta(e: WheelEvent, dpr: number): boolean { + const normalizedDeltaY = e.deltaY * dpr; + const normalizedDeltaX = e.deltaX * dpr; + return ( + !Number.isInteger(e.deltaY) || + !Number.isInteger(e.deltaX) || + !Number.isInteger(normalizedDeltaY) || + !Number.isInteger(normalizedDeltaX) + ); +} + +function isSmallWheelDelta(e: WheelEvent): boolean { + return Math.abs(e.deltaY) < SMALL_DELTA_THRESHOLD && Math.abs(e.deltaX) < SMALL_DELTA_THRESHOLD; +} + +function isPinchTrackpadGesture(e: WheelEvent, hasFractionalDelta: boolean, isSmallDelta: boolean): boolean { + return (e.ctrlKey || e.metaKey) && (hasFractionalDelta || isSmallDelta); +} + +function isDiagonalTrackpadScroll(e: WheelEvent): boolean { + return ( + !e.shiftKey && Math.abs(e.deltaX) > TRACKPAD_DIAGONAL_MIN_ABS && Math.abs(e.deltaY) > TRACKPAD_DIAGONAL_MIN_ABS + ); +} + +/** + * Strong per-event / stream signals that indicate trackpad (before sticky-session fallback). + */ +function hasStrongTrackpadSignals( + e: WheelEvent, + hasFractionalDelta: boolean, + isSmallDelta: boolean, + isRapidStream: boolean +): boolean { + if (isPinchTrackpadGesture(e, hasFractionalDelta, isSmallDelta)) { + return true; + } + if (isDiagonalTrackpadScroll(e)) { + return true; + } + // Large one-axis steps in a tight stream are still mouse smooth-scroll, not trackpad inertia. + if (isRapidStream && (isSmallDelta || (hasFractionalDelta && !isDominantAxisLargeWheel(e)))) { + // Clear horizontal component in rapid stream → strong trackpad signal. + if (!isVerticalOnlyForRapidStreamRule(e)) { + return true; + } + // Vertical-dominant (|deltaX| < 1.5): on many macOS setups trackpad reports integer pixel + // deltas while smoothed mouse wheel uses fractional deltas — disambiguate long scroll here. + return !hasFractionalDelta; + } + if (hasFractionalDelta) { + // Vertical-only fractional deltas: mouse smooth scroll (Chrome/macOS); trackpad noise on X is common. + if (isVerticalOnlyWheelEvent(e)) { + return false; + } + if (isDominantAxisLargeWheel(e)) { + return false; + } + return true; + } + if (Math.abs(e.deltaX) >= MIN_HORIZONTAL_SCROLL_ABS && !e.shiftKey && (isRapidStream || isSmallDelta)) { + return true; + } + return false; +} + +function tryClearStickyForMouseLikeVerticalFractional( + e: WheelEvent, + hasStrongSignals: boolean, + isRapidStream: boolean, + hasFractionalDelta: boolean, + wasTrackpad: boolean, + lastAt: number | null +): { trackpad: boolean; lastTime: number | null; rejected: boolean } { + if ( + wasTrackpad && + lastAt !== null && + !hasStrongSignals && + isVerticalOnlyWheelEvent(e) && + !isRapidStream && + hasFractionalDelta && + Math.abs(e.deltaY) < SMALL_DELTA_THRESHOLD + ) { + return { trackpad: false, lastTime: null, rejected: true }; + } + return { trackpad: wasTrackpad, lastTime: lastAt, rejected: false }; +} + /** * Creates a trackpad detection function that distinguishes between trackpad and mouse wheel events. * - * This factory function returns a detector that analyzes WheelEvent characteristics to determine - * the input device type. The detection is based on several behavioral patterns: + * Uses a hybrid of per-event and stream-based heuristics (similar to common canvas editors): * - * - **Pinch-to-zoom gestures**: Trackpads generate wheel events with modifier keys (Ctrl/Meta) - * and continuous (usually fractional) delta values. - * - **Horizontal scrolling**: Trackpads naturally produce horizontal scroll events (deltaX), - * while mice typically only scroll vertically (unless Shift is pressed). - * - **Continuous scrolling**: Trackpad scroll deltas are usually fractional or very small values, - * while mouse wheels produce discrete larger integer values (typically 100 or 120). + * - **Pinch-to-zoom**: wheel + Ctrl/Meta with small or fractional deltas. + * - **Diagonal scroll**: meaningful delta on both axes at once (uncommon for a physical wheel). + * - **High-frequency stream**: short intervals plus small/fractional deltas. When |deltaX| < 1.5 and the + * stream is rapid, integer deltas favor trackpad and fractional deltas favor smoothed mouse wheel + * (observed on some macOS + Chrome setups; devices vary). + * - **Fractional deltas**: only when there is horizontal component (|deltaX| ≥ 0.5); vertical-only + * fractional deltas are treated as mouse (Chrome/macOS smooth wheel). Dominant-axis large steps skip. + * - **Horizontal scroll**: requires |deltaX| ≥ 2 and a rapid stream or small deltas (avoids deltaX=1 noise). * - * The detector maintains state across events to provide consistent results during a scroll session. - * Once a trackpad is detected, the state persists for 60 seconds before resetting. + * The detector keeps session state across events. Once a trackpad is detected, the result can + * stay consistent for up to one minute before resetting (unless DPR + * changes or a discrete mouse wheel step is observed). * * @returns A detection function that accepts WheelEvent and optional devicePixelRatio * @@ -23,11 +160,11 @@ const TRACKPAD_DETECTION_STATE_TIMEOUT = 60_000; // 1 minute * ```typescript * const isTrackpad = isTrackpadDetector(); * - * element.addEventListener('wheel', (e) => { + * element.addEventListener("wheel", (e) => { * if (isTrackpad(e)) { - * console.log('Trackpad scroll detected'); + * // Trackpad scroll * } else { - * console.log('Mouse wheel detected'); + * // Mouse wheel * } * }); * ``` @@ -37,10 +174,8 @@ function isTrackpadDetector() { let lastDetectionTime: number | null = null; let lastDpr = 0; - /** - * Marks the current input device as trackpad and records the detection time. - * This ensures consistent detection during continuous scroll operations. - */ + let lastWheelTimestamp: number | null = null; + const markAsTrackpad = (dpr: number): void => { isTrackpadDetected = true; lastDetectionTime = performance.now(); @@ -52,77 +187,64 @@ function isTrackpadDetector() { * * @param e - The WheelEvent to analyze * @param dpr - Device pixel ratio for normalizing delta values. Defaults to window.devicePixelRatio. - * This normalization accounts for browser zoom levels. * @returns `true` if the event is from a trackpad, `false` if from a mouse wheel */ return (e: WheelEvent, dpr: number = globalThis.devicePixelRatio || 1) => { const now = performance.now(); + const timeSinceLastWheel = lastWheelTimestamp !== null ? now - lastWheelTimestamp : Number.POSITIVE_INFINITY; + const isRapidStream = timeSinceLastWheel < TRACKPAD_HIGH_FREQUENCY_MS; + + try { + if (isTrackpadDetected && lastDetectionTime !== null) { + if (now - lastDetectionTime >= TRACKPAD_DETECTION_STATE_TIMEOUT || lastDpr !== dpr) { + isTrackpadDetected = false; + lastDetectionTime = null; + } + } + + const hasFractionalDelta = hasFractionalWheelDelta(e, dpr); + const isSmallDelta = isSmallWheelDelta(e); + + const isLikelyMouseWheelStep = !isRapidStream && isDominantAxisLargeWheel(e); - // Fast path: if we have valid cached detection state and DPR hasn't changed, - // we already know it's a trackpad. We refresh the detection if we see more evidence below. - if (isTrackpadDetected && lastDetectionTime !== null) { - if (now - lastDetectionTime < TRACKPAD_DETECTION_STATE_TIMEOUT && lastDpr === dpr) { - // We'll continue to check for new evidence to refresh the timeout - } else { - // State expired or DPR changed - need to re-evaluate + if (isTrackpadDetected && isLikelyMouseWheelStep) { isTrackpadDetected = false; lastDetectionTime = null; } - } - const normalizedDeltaY = e.deltaY * dpr; - const normalizedDeltaX = e.deltaX * dpr; - - // Detection 1: Small or fractional deltas - // Trackpads produce fractional values or very small integers. - // Mouse produce large integers - const hasFractionalDelta = - !Number.isInteger(e.deltaY) || - !Number.isInteger(e.deltaX) || - !Number.isInteger(normalizedDeltaY) || - !Number.isInteger(normalizedDeltaX); - - const isSmallDelta = Math.abs(e.deltaY) < 50 && Math.abs(e.deltaX) < 50; - - // Detection 2: Pinch-to-zoom gesture - // Trackpad pinch-to-zoom generates wheel events with ctrlKey or metaKey. - // Combined with small or fractional deltas, this is a very strong indicator of trackpad. - // Note: Mouse wheel with Ctrl pressed usually has large delta (e.g., 100). - if ((e.ctrlKey || e.metaKey) && (hasFractionalDelta || isSmallDelta)) { - markAsTrackpad(dpr); - return true; - } + const strongSignals = hasStrongTrackpadSignals(e, hasFractionalDelta, isSmallDelta, isRapidStream); - // Detection 3: Horizontal scroll (deltaX) - // Trackpad naturally produces horizontal scroll events. - // Note: When Shift is pressed, browsers swap deltaX and deltaY for mouse wheel, - // so we skip this check to avoid false positives from mouse. - if (normalizedDeltaX !== 0 && !e.shiftKey) { - markAsTrackpad(dpr); - return true; - } + const stickyMouse = tryClearStickyForMouseLikeVerticalFractional( + e, + strongSignals, + isRapidStream, + hasFractionalDelta, + isTrackpadDetected, + lastDetectionTime + ); + if (stickyMouse.rejected) { + isTrackpadDetected = stickyMouse.trackpad; + lastDetectionTime = stickyMouse.lastTime; + } - // Detection 4: Smooth scrolling - // If we have non-integer values, it's almost certainly a trackpad or high-precision scroll. - if (hasFractionalDelta) { - markAsTrackpad(dpr); - return true; - } + if (strongSignals) { + markAsTrackpad(dpr); + return true; + } - // Fallback: If we already detected a trackpad recently, stay in trackpad mode - // unless we see obvious mouse-like behavior (large integer deltas). - if (isTrackpadDetected && lastDetectionTime !== null) { - // If it's still small delta, refresh the timestamp to keep trackpad state alive - if (isSmallDelta) { - lastDetectionTime = now; + if (isTrackpadDetected && lastDetectionTime !== null) { + if (isSmallDelta) { + lastDetectionTime = now; + } + return true; } - return true; - } - // No trackpad detected - isTrackpadDetected = false; - lastDetectionTime = null; - return false; + isTrackpadDetected = false; + lastDetectionTime = null; + return false; + } finally { + lastWheelTimestamp = now; + } }; } @@ -135,17 +257,20 @@ function isTrackpadDetector() { * * @example * ```typescript - * import { isTrackpadWheelEvent } from './utils/functions'; + * import { isTrackpadWheelEvent } from "./utils/functions"; * - * canvas.addEventListener('wheel', (event) => { + * canvas.addEventListener("wheel", (event) => { * if (isTrackpadWheelEvent(event)) { - * // Handle smooth trackpad scrolling * applyMomentumScrolling(event); * } else { - * // Handle discrete mouse wheel steps * applySteppedScrolling(event); * } * }); * ``` */ export const isTrackpadWheelEvent = isTrackpadDetector(); + +/** Default `TResolveWheelDevice`: built-in heuristics from {@link isTrackpadWheelEvent}. */ +export function defaultResolveWheelDevice(event: WheelEvent): EWheelDeviceKind { + return isTrackpadWheelEvent(event) ? EWheelDeviceKind.Trackpad : EWheelDeviceKind.Mouse; +} From b74d8313d4f00638ef4b046478579c8113b8c4da Mon Sep 17 00:00:00 2001 From: draedful Date: Fri, 20 Mar 2026 19:06:44 +0300 Subject: [PATCH 2/2] ... --- .../mouseWheelBehaviorScroll.stories.tsx | 140 ++++++++++++++---- 1 file changed, 110 insertions(+), 30 deletions(-) diff --git a/src/stories/examples/mouseWheelBehaviorScroll/mouseWheelBehaviorScroll.stories.tsx b/src/stories/examples/mouseWheelBehaviorScroll/mouseWheelBehaviorScroll.stories.tsx index cf1e080e..f4fe25bb 100644 --- a/src/stories/examples/mouseWheelBehaviorScroll/mouseWheelBehaviorScroll.stories.tsx +++ b/src/stories/examples/mouseWheelBehaviorScroll/mouseWheelBehaviorScroll.stories.tsx @@ -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 = { + 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.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(null); + + const graphParams = useMemo( + () => ({ + 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) { @@ -75,22 +108,69 @@ function GraphWithMouseWheelBehaviorScroll() { }); }, [setEntities]); - const renderBlockFn = useFn((graph: Graph, block: TBlock) => { + const renderBlockFn = useFn((g: Graph, block: TBlock) => { return ( - + {block.id.toLocaleString()} ); }); - return ; + return ( + + + + + MOUSE_WHEEL_BEHAVIOR (constants): {mouseWheelBehavior} — change via + Storybook Controls + + + Resolved wheel device (from resolveWheelDevice / heuristics):{" "} + {resolvedWheelDevice === null ? ( + "scroll over the canvas with a mouse or trackpad" + ) : ( + {DEVICE_LABEL[resolvedWheelDevice]} + )} + + +
+ +
+
+
+ ); } -export const Default: StoryFn = () => ; - -const meta: Meta = { +const meta: Meta = { 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; + +export const Default: Story = {};