From ba95eb51d9f4ebfca9c8171161d5e5425a85bb91 Mon Sep 17 00:00:00 2001 From: draedful Date: Fri, 26 Jun 2026 16:58:20 +0300 Subject: [PATCH 1/3] fix(wheel): improve cross-platform wheel intent and suppress text selection on drag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Detect legacy mouse wheelDelta (±120) to prevent Windows mouse scroll from being misclassified as trackpad pan, extend modifier+PIXEL rule for Mac Cmd zoom, and add optional text selection suppression during camera/block drags. Includes WheelIntentProbe Storybook for live wheel event debugging. Co-authored-by: Cursor --- docs/system/camera.md | 4 +- docs/system/wheel-intent.md | 40 ++- src/services/camera/Camera.ts | 3 + src/services/drag/DragService.ts | 11 +- src/services/drag/types.ts | 5 + .../wheelIntentProbe/WheelEventLogPanel.css | 96 +++++++ .../wheelIntentProbe/WheelEventLogPanel.tsx | 251 ++++++++++++++++++ .../wheelIntentProbe/wheelEventCapture.ts | 114 ++++++++ .../wheelIntentProbe.stories.tsx | 196 ++++++++++++++ src/utils/functions/dragListener.ts | 44 ++- src/utils/functions/wheelIntent.test.ts | 104 +++++++- src/utils/functions/wheelIntent.ts | 71 ++++- 12 files changed, 908 insertions(+), 31 deletions(-) create mode 100644 src/stories/examples/wheelIntentProbe/WheelEventLogPanel.css create mode 100644 src/stories/examples/wheelIntentProbe/WheelEventLogPanel.tsx create mode 100644 src/stories/examples/wheelIntentProbe/wheelEventCapture.ts create mode 100644 src/stories/examples/wheelIntentProbe/wheelIntentProbe.stories.tsx diff --git a/docs/system/camera.md b/docs/system/camera.md index 8f37c6c7..327373a1 100644 --- a/docs/system/camera.md +++ b/docs/system/camera.md @@ -103,11 +103,11 @@ const graph = new Graph(canvas, { - With Shift: Horizontal scrolling (left/right along X axis) **Important notes:** -- `MOUSE_WHEEL_BEHAVIOR` affects **mouse wheel** classification in `resolveWheelIntent` (I4 rules). It does **not** change integer trackpad scroll, which always resolves to pan. +- `MOUSE_WHEEL_BEHAVIOR` affects **mouse wheel** classification in `resolveWheelIntent` (I4 rules), including large integer PIXEL steps on Chromium (Windows/Edge). Small integer trackpad ticks and rapid-stream trackpad scroll always resolve to pan (I3). - Scroll direction switching with Shift is an environment-dependent behavior according to [W3C UI Events specification](https://w3c.github.io/uievents/#events-wheelevents). - Different browsers and operating systems may handle Shift+wheel differently. - **Trackpad** gestures also pass through `resolveWheelIntent` (see [Wheel Intent Resolution](./wheel-intent.md)): - - Two-finger swipe (integer PIXEL deltas) → pan + - Two-finger swipe (small integer PIXEL, or large integer in a rapid stream) → pan - Pinch-to-zoom (Ctrl/Cmd + scroll) → zoom with `PINCH_ZOOM_SPEED` - Horizontal / diagonal two-finger swipe → pan (I2) - Settings can be updated at runtime using `graph.setConstants()`. diff --git a/docs/system/wheel-intent.md b/docs/system/wheel-intent.md index 0c8c651e..c473d0bd 100644 --- a/docs/system/wheel-intent.md +++ b/docs/system/wheel-intent.md @@ -42,25 +42,36 @@ Rules are evaluated in priority order; the first match wins. Debug logs use **ru ### Trackpad vs mouse: `deltaMode` and delta shape -Trackpads **always** emit `deltaMode === 0` (`DOM_DELTA_PIXEL`). Mechanical mouse wheels use `LINE` (1) or `PAGE` (2), or smooth-scroll **fractional** PIXEL deltas. +Trackpads **always** emit `deltaMode === 0` (`DOM_DELTA_PIXEL`). On Chromium (Chrome/Edge), **mouse wheels also use PIXEL mode** with integer deltas (often ±100 per notch). Firefox still uses LINE/PAGE for mice. | Signal | Source | Resolver | |--------|--------|----------| | `deltaMode !== 0` (LINE/PAGE) | **Mouse** wheel | **I4** per `MOUSE_WHEEL_BEHAVIOR` | -| `deltaMode === 0` + **integer** `deltaX`/`deltaY` | **Trackpad** | **Pan** (`I3:integer-trackpad` / `I3:integer-trackpad-slow`) | +| `deltaMode === 0` + **small integer** `deltaX`/`deltaY` (< 20 px) | **Trackpad** | **Pan** (I3) | +| `deltaMode === 0` + **large integer** in rapid stream (< 38 ms) | **Trackpad** fast scroll | **Pan** (I3) | +| `deltaMode === 0` + **isolated large integer** (≥ 20 px, not rapid) | **Mouse** (Chromium) | **I4** per `MOUSE_WHEEL_BEHAVIOR` | +| `deltaMode === 0` + **legacy wheelDelta ≈ ±120** | **Mouse** (Chromium/Edge) | **I4** (never I3, even in rapid stream) | | `deltaMode === 0` + **fractional** deltas | **Mouse** smooth-scroll | **I4** per `MOUSE_WHEEL_BEHAVIOR` | Integer check uses `Number.isInteger` on raw `deltaX` and `deltaY` (PIXEL mode only). --- -### I1 — Pinch-to-zoom +### I1 — Trackpad modifier zoom (pinch / Cmd / Ctrl+scroll) -**Condition**: `(ctrlKey || metaKey)` AND `(hasFractionalDelta || isSmallDelta)` +**Condition**: `(ctrlKey || metaKey) && deltaMode === PIXEL` **Intent**: Zoom (`I1:pinch`) -The OS synthesises `ctrlKey=true` for trackpad pinch-to-zoom. Requiring fractional-or-small delta filters out Ctrl+scroll on a mechanical wheel (large steps, typically ≥ 20 px on the smooth-scroll ramp). +Trackpads always emit `deltaMode === 0` (PIXEL). When a zoom modifier is held, every wheel tick is zoom — including fast scroll with **integer** deltas and the fractional inertia tail: + +| Platform | Modifier | Gesture | +|----------|----------|---------| +| macOS | `metaKey` | Cmd + two-finger scroll | +| macOS | `ctrlKey` | Pinch-to-zoom (OS-synthesised) | +| Windows / Linux | `ctrlKey` | Ctrl + two-finger scroll | + +Mechanical mouse wheels use LINE/PAGE (`deltaMode !== 0`) and stay on **I4** per `MOUSE_WHEEL_BEHAVIOR`. Camera applies `PINCH_ZOOM_SPEED` when {@link isPinchZoomGesture} is true (same condition as I1). @@ -78,14 +89,21 @@ Ignores small `deltaX` noise on predominantly vertical mouse wheel ticks (trackp ### I3 — Integer trackpad (PIXEL mode) -**Condition**: `deltaMode === 0` + integer `deltaX`/`deltaY` (any magnitude; horizontal/diagonal handled by I2 first) +**Condition**: `deltaMode === 0` + integer `deltaX`/`deltaY`, and either: + +- both axes **< 20 px** (normalized peak), **or** +- previous event **< 38 ms** ago (rapid stream) with peak **≥ 20 px** + +Horizontal/diagonal gestures are handled by I2 first. **Intent**: Pan | Timing | Rule id | |--------|---------| | Rapid stream (< 38 ms since previous event) | `I3:integer-trackpad` | -| Not rapid | `I3:integer-trackpad-slow` | +| Not rapid (small ticks only) | `I3:integer-trackpad-slow` | + +Isolated large integer PIXEL steps without legacy `wheelDelta` fall through to **I4** via `isDominantAxisLargeWheel`. Events with **legacy `wheelDelta(Y)` ≈ ±120** (Chromium mechanical mouse on Windows) skip **I3** entirely — including rapid streams (~33 ms apart). --- @@ -100,7 +118,7 @@ Ignores small `deltaX` noise on predominantly vertical mouse wheel ticks (trackp | Slow fractional notch | slow, vertical-only, fractional PIXEL in **~3–5 px** band (e.g. Δy ≈ -4.000244) | `I4:fractional-mouse` | | Burst smoothing | within **120 ms** after an I4 step, vertical-only fractional event | `I4-burst:smoothing` | -Integer PIXEL steps are trackpad — handled by I3, never I4. `I4:fractional-mouse` starts the burst window. +Integer PIXEL steps **≥ 20 px** outside a rapid stream are mouse (I4), not I3. `I4:fractional-mouse` starts the burst window. --- @@ -125,13 +143,13 @@ Integer PIXEL steps are trackpad — handled by I3, never I4. `I4:fractional-mou ``` WheelEvent arrives │ -├─ I1: ctrl/meta + (fractional or small)? +├─ I1: (ctrlKey or metaKey) + PIXEL delta? │ → Zoom │ ├─ I2: diagonal OR predominant horizontal? │ → Pan │ -├─ I3: integer PIXEL delta (trackpad)? +├─ I3: small integer PIXEL, or large integer in rapid stream? │ → Pan (I3:integer-trackpad / I3:integer-trackpad-slow) │ ├─ I4: mouse wheel step OR large single-axis step? @@ -243,4 +261,6 @@ enableWheelIntentDebug((entry) => { Debug hooks are explicit opt-in. Each wheel event logs two plain-text lines: a one-line summary and a `JSON.stringify` payload you can copy from the console. +**Storybook dev stand:** run `npm run storybook` → **Dev / WheelIntentProbe** — live table of raw `WheelEvent` fields plus resolver rule/signals; copy JSON after reproducing on your OS/device. + See also [Camera](./camera.md) for `MOUSE_WHEEL_BEHAVIOR` and camera constants. diff --git a/src/services/camera/Camera.ts b/src/services/camera/Camera.ts index 497844bd..5f820665 100644 --- a/src/services/camera/Camera.ts +++ b/src/services/camera/Camera.ts @@ -195,6 +195,9 @@ export class Camera extends EventedComponent this.onDragStart(event)) diff --git a/src/services/drag/DragService.ts b/src/services/drag/DragService.ts index 8bf11ec1..6dc67ef6 100644 --- a/src/services/drag/DragService.ts +++ b/src/services/drag/DragService.ts @@ -316,7 +316,15 @@ export class DragService { * ``` */ public startDrag(callbacks: DragOperationCallbacks, options: DragOperationOptions = {}) { - const { document: doc, cursor, autopanning = true, stopOnMouseLeave, threshold, initialEvent } = options; + const { + document: doc, + cursor, + autopanning = true, + stopOnMouseLeave, + threshold, + initialEvent, + suppressTextSelection, + } = options; const { onStart, onUpdate, onEnd } = callbacks; const targetDocument = doc ?? this.graph.getGraphCanvas().ownerDocument; @@ -327,6 +335,7 @@ export class DragService { autopanning, stopOnMouseLeave, threshold, + suppressTextSelection, }) .on(EVENTS.DRAG_START, (event: MouseEvent) => { onStart?.(event, initialEvent ? this.getWorldCoords(initialEvent) : this.getWorldCoords(event)); diff --git a/src/services/drag/types.ts b/src/services/drag/types.ts index 4f6d44f1..fb3a6e28 100644 --- a/src/services/drag/types.ts +++ b/src/services/drag/types.ts @@ -24,6 +24,11 @@ export type DragOperationOptions = { * If not set, uses the first mousemove event. */ initialEvent?: MouseEvent; + /** + * Blocks browser text selection for the drag session. + * @default true + */ + suppressTextSelection?: boolean; }; /** diff --git a/src/stories/examples/wheelIntentProbe/WheelEventLogPanel.css b/src/stories/examples/wheelIntentProbe/WheelEventLogPanel.css new file mode 100644 index 00000000..d8c424f4 --- /dev/null +++ b/src/stories/examples/wheelIntentProbe/WheelEventLogPanel.css @@ -0,0 +1,96 @@ +.wheel-probe-panel { + box-sizing: border-box; + height: 100%; + padding: 12px 16px; + overflow: hidden; + background: var(--g-color-base-background); + border-left: 1px solid var(--g-color-line-generic); +} + +.wheel-probe-table-wrap { + flex: 1; + min-height: 120px; + overflow: auto; + border: 1px solid var(--g-color-line-generic); + border-radius: 6px; +} + +.wheel-probe-table { + width: 100%; + border-collapse: collapse; + font-family: var(--g-font-family-monospace, ui-monospace, monospace); + font-size: 11px; +} + +.wheel-probe-table th, +.wheel-probe-table td { + padding: 4px 6px; + border-bottom: 1px solid var(--g-color-line-generic); + text-align: left; + white-space: nowrap; +} + +.wheel-probe-table th { + position: sticky; + top: 0; + z-index: 1; + background: var(--g-color-base-generic); +} + +.wheel-probe-row { + cursor: pointer; +} + +.wheel-probe-row:hover { + background: var(--g-color-base-simple-hover); +} + +.wheel-probe-row_selected { + background: var(--g-color-base-selection); +} + +.wheel-probe-row_zoom td:last-child { + color: var(--g-color-text-warning, #ff7700); + font-weight: 600; +} + +.wheel-probe-row_pan td:last-child { + color: var(--g-color-text-info, #0066ff); + font-weight: 600; +} + +.wheel-probe-empty { + padding: 16px; + text-align: center; + color: var(--g-color-text-secondary); +} + +.wheel-probe-detail { + flex-shrink: 0; + max-height: 35%; +} + +.wheel-probe-hidden-copy { + position: fixed; + top: 0; + left: 0; + width: 1px; + height: 1px; + opacity: 0; + pointer-events: none; +} + +.wheel-probe-json { + margin: 0; + padding: 8px; + overflow: auto; + max-height: 220px; + border: 1px solid var(--g-color-line-generic); + border-radius: 6px; + font-family: var(--g-font-family-monospace, ui-monospace, monospace); + font-size: 11px; + line-height: 1.4; + background: var(--g-color-base-generic); + user-select: text; + cursor: text; +} diff --git a/src/stories/examples/wheelIntentProbe/WheelEventLogPanel.tsx b/src/stories/examples/wheelIntentProbe/WheelEventLogPanel.tsx new file mode 100644 index 00000000..c14b690e --- /dev/null +++ b/src/stories/examples/wheelIntentProbe/WheelEventLogPanel.tsx @@ -0,0 +1,251 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import { Button, Flex, Label, Switch, Text, TextInput } from "@gravity-ui/uikit"; + +import type { TWheelProbeLogEntry } from "./wheelEventCapture"; +import { copyTextToClipboard, formatWheelProbeSummary } from "./wheelEventCapture"; + +import "./WheelEventLogPanel.css"; + +const MAX_ENTRIES_CAP = 500; + +type TWheelEventLogPanelProps = { + entries: TWheelProbeLogEntry[]; + mouseWheelBehavior: string; + paused: boolean; + onPausedChange: (paused: boolean) => void; + onClear: () => void; +}; + +export function WheelEventLogPanel({ + entries, + mouseWheelBehavior, + paused, + onPausedChange, + onClear, +}: TWheelEventLogPanelProps): React.ReactElement { + const [selectedId, setSelectedId] = useState(null); + const [filter, setFilter] = useState(""); + const [copyFeedback, setCopyFeedback] = useState(null); + const jsonPreRef = useRef(null); + const hiddenCopyRef = useRef(null); + const copyFeedbackTimerRef = useRef(null); + + useEffect(() => { + return () => { + if (copyFeedbackTimerRef.current !== null) { + window.clearTimeout(copyFeedbackTimerRef.current); + } + }; + }, []); + + const showCopyFeedback = useCallback((message: string): void => { + setCopyFeedback(message); + if (copyFeedbackTimerRef.current !== null) { + window.clearTimeout(copyFeedbackTimerRef.current); + } + copyFeedbackTimerRef.current = window.setTimeout(() => { + setCopyFeedback(null); + copyFeedbackTimerRef.current = null; + }, 4000); + }, []); + + const selectForManualCopy = useCallback( + (text: string, selectJsonFallback: boolean): void => { + if (selectJsonFallback && jsonPreRef.current) { + const range = document.createRange(); + range.selectNodeContents(jsonPreRef.current); + const selection = window.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(range); + } else if (hiddenCopyRef.current) { + hiddenCopyRef.current.value = text; + hiddenCopyRef.current.focus({ preventScroll: true }); + hiddenCopyRef.current.select(); + } + showCopyFeedback("Auto-copy blocked — text selected, press Ctrl/Cmd+C"); + }, + [showCopyFeedback] + ); + + const copyFromHiddenTextarea = useCallback((text: string): boolean => { + const textarea = hiddenCopyRef.current; + if (textarea === null) { + return copyTextToClipboard(text); + } + + try { + textarea.value = text; + textarea.focus({ preventScroll: true }); + textarea.select(); + textarea.setSelectionRange(0, text.length); + return document.execCommand("copy"); + } catch { + return false; + } + }, []); + + const runCopy = useCallback( + (text: string, selectJsonFallback: boolean): void => { + // Sync copy must run in the click handler — await breaks the user-gesture chain in Storybook. + if (copyFromHiddenTextarea(text)) { + showCopyFeedback("Copied to clipboard"); + return; + } + + const clipboard = navigator.clipboard; + if (clipboard?.writeText) { + clipboard.writeText(text).then( + () => { + showCopyFeedback("Copied to clipboard"); + }, + () => { + selectForManualCopy(text, selectJsonFallback); + } + ); + return; + } + + selectForManualCopy(text, selectJsonFallback); + }, + [copyFromHiddenTextarea, selectForManualCopy, showCopyFeedback] + ); + + const filteredEntries = useMemo(() => { + const q = filter.trim().toLowerCase(); + if (!q) { + return entries; + } + return entries.filter((entry) => formatWheelProbeSummary(entry).toLowerCase().includes(q)); + }, [entries, filter]); + + const selectedEntry = useMemo(() => entries.find((entry) => entry.id === selectedId) ?? null, [entries, selectedId]); + + const copyAll = useCallback((): void => { + runCopy(JSON.stringify(entries, null, 2), false); + }, [entries, runCopy]); + + const copySelected = useCallback((): void => { + if (selectedEntry === null) { + return; + } + runCopy(JSON.stringify(selectedEntry, null, 2), true); + }, [selectedEntry, runCopy]); + + const selectedJsonText = + selectedEntry === null ? "Click a row to inspect full payload" : JSON.stringify(selectedEntry, null, 2); + + const platformHint = entries[0]?.platform ?? navigator.platform; + + return ( + +