Skip to content

Add wheel support to Blazor WASM views with v120 normalization #3534

@mattleibow

Description

@mattleibow

Overview

Add scroll wheel support to SkiaSharp's Blazor WASM views (SKCanvasView and SKGLView) with values normalized to the v120 standard (120 = one discrete mouse wheel notch).

Parent issue: #3533

Platform Details

Native API

The browser exposes wheel events via the WheelEvent interface with two key properties:

  • WheelEvent.deltaY — the scroll amount (double)
  • WheelEvent.deltaMode — the unit of the delta value:
    • 0 (DOM_DELTA_PIXEL) — value is in CSS pixels
    • 1 (DOM_DELTA_LINE) — value is in lines
    • 2 (DOM_DELTA_PAGE) — value is in pages

Raw Values Per Discrete Mouse Notch

⚠️ Important: The W3C spec explicitly states: "authors can not assume a given rotation amount will produce the same delta value in all user agents" and that "precise measurement is specific to device, operating system, and application configurations"W3C Pointer Events 4. The values below are observed defaults and may vary.

Browser OS deltaMode Per notch value Source
Chrome/Edge Windows 0 (pixel) ~100 Chromium default
Chrome/Edge macOS 0 (pixel) ~100 (varies by device) Device-dependent
Safari macOS 0 (pixel) ~100 Device-dependent (measured same as Chrome on macOS)
Firefox Windows/macOS 1 (line) ~3 Default for mouse; trackpads use pixel mode (0)
Firefox Windows/macOS 0 (pixel) ~100 When dom.event.wheel-deltaMode-lines.disabled=true (non-default)
Firefox Linux 1 (line) ~3 Default on Linux

Trackpad / Fine Scroll Values

Device deltaMode Typical values
Trackpad (any browser) 0 (pixel) ±1 to ±10 per event (fractional movement)
Magic Mouse 0 (pixel) ±1 to ±10 (similar to trackpad)
High-res mouse 0 (pixel) sub-100 values at higher frequency

Sign Convention

  • deltaY > 0 = scroll down (toward user)
  • deltaY < 0 = scroll up (away from user)
  • Must negate to match v120 standard (positive = up)

Important Notes

  • The "16px per line" sometimes referenced in browser internals is a de-facto convention for rendering, NOT tied to CSS font-size or line-height. Our normalization does not use this value — we convert lines directly to v120 units.
  • deltaMode=2 (page) is rare in practice — mainly from some accessibility tools.
  • All major pixel-mode browsers (Chrome, Edge, Safari) converge on ~100 CSS pixels per notch. The original estimate of Safari at ~120px was incorrect — Safari reports ~100px, same as Chromium-based browsers.
  • deltaMode MUST be checked — the same deltaY value means completely different things in pixel vs line vs page mode.

Official Documentation

Normalization Logic

// Normalize browser wheel delta to v120 standard (120 = one discrete notch).
// Sign: positive = scroll up (away from user), matching Windows convention.
//
// NOTE: Per-notch pixel values are browser/OS/device dependent.
// These constants represent the most common observed defaults.
// The W3C spec explicitly states authors cannot assume consistent
// delta values across user agents.
const WHEEL_DELTA = 120;
const PIXELS_PER_NOTCH = 100;  // Chrome/Edge/Safari de-facto default (~100 CSS px per notch)
const LINES_PER_NOTCH = 3;     // Firefox default (follows OS 'lines per notch' setting; 3 is Windows/macOS default)

let delta: number;
if (e.deltaMode === WheelEvent.DOM_DELTA_LINE) {
    // Firefox line mode: ~3 lines per notch → 3 × 40 = 120
    delta = Math.round(-e.deltaY * (WHEEL_DELTA / LINES_PER_NOTCH));
} else if (e.deltaMode === WheelEvent.DOM_DELTA_PAGE) {
    // Rare: ~1 page per notch → 1 × 120 = 120
    delta = Math.round(-e.deltaY * WHEEL_DELTA);
} else {
    // DOM_DELTA_PIXEL (default, most common)
    // Chrome: ~100px per notch → 100 × 1.2 = 120
    // Trackpad: 5px → 6, 1px → 1 (no dead zone)
    delta = Math.round(-e.deltaY * (WHEEL_DELTA / PIXELS_PER_NOTCH));
}

Expected Results

Input Calculation WheelDelta
Chrome mouse notch (100px) round(-(-100) × 1.2) 120
Firefox mouse notch (3 lines) round(-(-3) × 40) 120
Safari mouse notch (100px) round(-(-100) × 1.2) 120
Trackpad 5px down round(-(5) × 1.2) -6
Trackpad 1px up round(-(-1) × 1.2) 1
Page mode 1 page round(-(-1) × 120) 120
Horizontal only (deltaY=0) round(0 × anything) 0

Implementation Notes

  • Listen for wheel event on the canvas element (alongside existing pointer events)
  • Fire SKTouchAction.WheelChanged with SKTouchDeviceType.Mouse
  • Pointer coordinates from e.clientX/Y - rect.left/top (same as pointer events)
  • wheelDelta is int in C# — Math.round() ensures integer on JS side
  • Must handle both NET7+ ([JSImport] / [JSExport]) and pre-NET7 (DotNetObjectReference.invokeMethod) interop paths
  • When Handled is returned true from C#, call e.preventDefault() and e.stopPropagation() to prevent browser default scroll behavior
  • Both SKCanvasView and SKGLView need wheel support
  • Update SKTouchInterop.ts AND SKTouchInterop.js (no TypeScript compiler in repo — JS is hand-maintained)

Implementation Status

The v120 normalization has been implemented on branch copilot/add-touch-event-blazor-canvas (commit 052d325bc).

Validated with Playwright across 3 browser engines:

  • Chromium: 24/24 tests passing
  • Firefox: 24/24 tests passing
  • WebKit (Safari): 24/24 tests passing

Key correction from original analysis

Safari on macOS reports ~100 CSS pixels per notch (same as Chrome/Edge), NOT ~120 as originally estimated. This was confirmed via web research and aligns with the Chromium source (kPixelsPerLineStep = 100). This means PIXELS_PER_NOTCH = 100 produces correct v120 values for ALL major pixel-mode browsers.

Research sources used for validation

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    Status

    New

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions