Skip to content
Merged
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
4 changes: 2 additions & 2 deletions docs/system/camera.md
Original file line number Diff line number Diff line change
Expand Up @@ -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()`.
Expand Down
40 changes: 30 additions & 10 deletions docs/system/wheel-intent.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 a 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).

Expand All @@ -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 (&lt; 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).

---

Expand All @@ -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.

---

Expand All @@ -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?
Expand Down Expand Up @@ -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 panel:** 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.
3 changes: 3 additions & 0 deletions src/services/camera/Camera.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,9 @@ export class Camera extends EventedComponent<TCameraProps, TComponentState, TGra
return;
}

// Left button on empty canvas — suppress native text selection before pan drag.
event.preventDefault();

// Camera drag doesn't need graph sync since it IS the camera
dragListener(this.ownerDocument, { graph: this.context.graph, autopanning: false, dragCursor: "grabbing" })
.on(EVENTS.DRAG_START, (event: MouseEvent) => this.onDragStart(event))
Expand Down
11 changes: 10 additions & 1 deletion src/services/drag/DragService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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));
Expand Down
5 changes: 5 additions & 0 deletions src/services/drag/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};

/**
Expand Down
96 changes: 96 additions & 0 deletions src/stories/examples/wheelIntentProbe/WheelEventLogPanel.css
Original file line number Diff line number Diff line change
@@ -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;
}
Loading
Loading