diff --git a/lib/web_ui/lib/src/engine/dom.dart b/lib/web_ui/lib/src/engine/dom.dart index 1cb4a75d565f6..4c1c958f93184 100644 --- a/lib/web_ui/lib/src/engine/dom.dart +++ b/lib/web_ui/lib/src/engine/dom.dart @@ -1126,6 +1126,8 @@ class DomWheelEvent extends DomMouseEvent {} extension DomWheelEventExtension on DomWheelEvent { external double get deltaX; external double get deltaY; + external double? get wheelDeltaX; + external double? get wheelDeltaY; external double get deltaMode; } diff --git a/lib/web_ui/lib/src/engine/pointer_binding.dart b/lib/web_ui/lib/src/engine/pointer_binding.dart index 639814d3a9dfb..1c3468597bc47 100644 --- a/lib/web_ui/lib/src/engine/pointer_binding.dart +++ b/lib/web_ui/lib/src/engine/pointer_binding.dart @@ -263,6 +263,8 @@ abstract class _BaseAdapter { final _PointerDataCallback _callback; final PointerDataConverter _pointerDataConverter; final KeyboardConverter _keyboardConverter; + DomWheelEvent? _lastWheelEvent; + bool _lastWheelEventWasTrackpad = false; /// Each subclass is expected to override this method to attach its own event /// listeners and convert events into pointer events. @@ -333,6 +335,71 @@ abstract class _BaseAdapter { mixin _WheelEventListenerMixin on _BaseAdapter { static double? _defaultScrollLineHeight; + bool _isAcceleratedMouseWheelDelta(num delta, num? wheelDelta) { + // On macOS, scrolling using a mouse wheel by default uses an acceleration + // curve, so delta values ramp up and are not at fixed multiples of 120. + // But in this case, the wheelDelta properties of the event still keep + // their original values. + // For all events without this acceleration curve applied, the wheelDelta + // values are by convention three times greater than the delta values and with + // the opposite sign. + if (wheelDelta == null) { + return false; + } + // Account for observed issues with integer truncation by allowing +-1px error. + return (wheelDelta - (-3 * delta)).abs() > 1; + } + + bool _isTrackpadEvent(DomWheelEvent event) { + // This function relies on deprecated and non-standard implementation + // details. Useful reference material can be found below. + // + // https://source.chromium.org/chromium/chromium/src/+/main:ui/events/event.cc + // https://source.chromium.org/chromium/chromium/src/+/main:ui/events/cocoa/events_mac.mm + // https://github.com/WebKit/WebKit/blob/main/Source/WebCore/platform/mac/PlatformEventFactoryMac.mm + // https://searchfox.org/mozilla-central/source/dom/events/WheelEvent.h + // https://learn.microsoft.com/en-us/windows/win32/inputdev/wm-mousewheel + if (browserEngine == BrowserEngine.firefox) { + // Firefox has restricted the wheelDelta properties, they do not provide + // enough information to accurately disambiguate trackpad events from mouse + // wheel events. + return false; + } + if (_isAcceleratedMouseWheelDelta(event.deltaX, event.wheelDeltaX) || + _isAcceleratedMouseWheelDelta(event.deltaY, event.wheelDeltaY)) { + return false; + } + if (((event.deltaX % 120 == 0) && (event.deltaY % 120 == 0)) || + (((event.wheelDeltaX ?? 1) % 120 == 0) && ((event.wheelDeltaY ?? 1) % 120) == 0)) { + // While not in any formal web standard, `blink` and `webkit` browsers use + // a delta of 120 to represent one mouse wheel turn. If both dimensions of + // the delta are divisible by 120, this event is probably from a mouse. + // Checking if wheelDeltaX and wheelDeltaY are both divisible by 120 + // catches any macOS accelerated mouse wheel deltas which by random chance + // are not caught by _isAcceleratedMouseWheelDelta. + final num deltaXChange = (event.deltaX - (_lastWheelEvent?.deltaX ?? 0)).abs(); + final num deltaYChange = (event.deltaY - (_lastWheelEvent?.deltaY ?? 0)).abs(); + if ((_lastWheelEvent == null) || + (deltaXChange == 0 && deltaYChange == 0) || + !(deltaXChange < 20 && deltaYChange < 20)) { + // A trackpad event might by chance have a delta of exactly 120, so + // make sure this event does not have a similar delta to the previous + // one before calling it a mouse event. + if (event.timeStamp != null && _lastWheelEvent?.timeStamp != null) { + // If the event has a large delta to the previous event, check if + // it was preceded within 50 milliseconds by a trackpad event. This + // handles unlucky 120-delta trackpad events during rapid movement. + final num diffMs = event.timeStamp! - _lastWheelEvent!.timeStamp!; + if (diffMs < 50 && _lastWheelEventWasTrackpad) { + return true; + } + } + return false; + } + } + return true; + } + List _convertWheelEventToPointerData( DomWheelEvent event ) { @@ -340,6 +407,11 @@ mixin _WheelEventListenerMixin on _BaseAdapter { const int domDeltaLine = 0x01; const int domDeltaPage = 0x02; + ui.PointerDeviceKind kind = ui.PointerDeviceKind.mouse; + if (_isTrackpadEvent(event)) { + kind = ui.PointerDeviceKind.trackpad; + } + // Flutter only supports pixel scroll delta. Convert deltaMode values // to pixels. double deltaX = event.deltaX; @@ -371,7 +443,7 @@ mixin _WheelEventListenerMixin on _BaseAdapter { data, change: ui.PointerChange.hover, timeStamp: _BaseAdapter._eventTimeStampToDuration(event.timeStamp!), - kind: ui.PointerDeviceKind.mouse, + kind: kind, signalKind: ui.PointerSignalKind.scroll, device: _mouseDeviceId, physicalX: event.clientX * ui.window.devicePixelRatio, @@ -382,6 +454,8 @@ mixin _WheelEventListenerMixin on _BaseAdapter { scrollDeltaX: deltaX, scrollDeltaY: deltaY, ); + _lastWheelEvent = event; + _lastWheelEventWasTrackpad = kind == ui.PointerDeviceKind.trackpad; return data; } diff --git a/lib/web_ui/test/engine/pointer_binding_test.dart b/lib/web_ui/test/engine/pointer_binding_test.dart index 88c02a05a3ac9..2f5f48c0252a6 100644 --- a/lib/web_ui/test/engine/pointer_binding_test.dart +++ b/lib/web_ui/test/engine/pointer_binding_test.dart @@ -1147,6 +1147,229 @@ void testMain() { }, ); + _testEach<_ButtonedEventMixin>( + <_ButtonedEventMixin>[ + if (!isIosSafari) _PointerEventContext(), + if (!isIosSafari) _MouseEventContext(), + ], + 'does set pointer device kind based on delta precision and wheelDelta', + (_ButtonedEventMixin context) { + if (isFirefox) { + // Firefox does not support trackpad events, as they cannot be + // disambiguated from smoothed mouse wheel events. + return; + } + PointerBinding.instance!.debugOverrideDetector(context); + final List packets = []; + ui.window.onPointerDataPacket = (ui.PointerDataPacket packet) { + packets.add(packet); + }; + + glassPane.dispatchEvent(context.wheel( + buttons: 0, + clientX: 10, + clientY: 10, + deltaX: 119, + deltaY: 119, + wheelDeltaX: -357, + wheelDeltaY: -357, + timeStamp: 0, + )); + + glassPane.dispatchEvent(context.wheel( + buttons: 0, + clientX: 10, + clientY: 10, + deltaX: 120, + deltaY: 120, + wheelDeltaX: -360, + wheelDeltaY: -360, + timeStamp: 10, + )); + + glassPane.dispatchEvent(context.wheel( + buttons: 0, + clientX: 10, + clientY: 10, + deltaX: 120, + deltaY: 120, + wheelDeltaX: -360, + wheelDeltaY: -360, + timeStamp: 20, + )); + + glassPane.dispatchEvent(context.wheel( + buttons: 0, + clientX: 10, + clientY: 10, + deltaX: 119, + deltaY: 119, + wheelDeltaX: -357, + wheelDeltaY: -357, + timeStamp: 1000, + )); + + glassPane.dispatchEvent(context.wheel( + buttons: 0, + clientX: 10, + clientY: 10, + deltaX: -120, + deltaY: -120, + wheelDeltaX: 360, + wheelDeltaY: 360, + timeStamp: 1010, + )); + + glassPane.dispatchEvent(context.wheel( + buttons: 0, + clientX: 10, + clientY: 10, + deltaX: 0, + deltaY: -120, + wheelDeltaX: 0, + wheelDeltaY: 360, + timeStamp: 2000, + )); + + glassPane.dispatchEvent(context.wheel( + buttons: 0, + clientX: 10, + clientY: 10, + deltaX: 0, + deltaY: 40, + wheelDeltaX: 0, + wheelDeltaY: -360, + timeStamp: 3000, + )); + + expect(packets, hasLength(7)); + + // An add will be synthesized. + expect(packets[0].data, hasLength(2)); + expect(packets[0].data[0].change, equals(ui.PointerChange.add)); + expect(packets[0].data[0].pointerIdentifier, equals(0)); + expect(packets[0].data[0].synthesized, isTrue); + expect(packets[0].data[0].physicalX, equals(10.0 * dpi)); + expect(packets[0].data[0].physicalY, equals(10.0 * dpi)); + expect(packets[0].data[0].physicalDeltaX, equals(0.0)); + expect(packets[0].data[0].physicalDeltaY, equals(0.0)); + // Because the delta is not in increments of 120 and has matching wheelDelta, + // it will be a trackpad event. + expect(packets[0].data[1].change, equals(ui.PointerChange.hover)); + expect( + packets[0].data[1].signalKind, equals(ui.PointerSignalKind.scroll)); + expect( + packets[0].data[1].kind, equals(ui.PointerDeviceKind.trackpad)); + expect(packets[0].data[1].pointerIdentifier, equals(0)); + expect(packets[0].data[1].synthesized, isFalse); + expect(packets[0].data[1].physicalX, equals(10.0 * dpi)); + expect(packets[0].data[1].physicalY, equals(10.0 * dpi)); + expect(packets[0].data[1].physicalDeltaX, equals(0.0)); + expect(packets[0].data[1].physicalDeltaY, equals(0.0)); + expect(packets[0].data[1].scrollDeltaX, equals(119.0)); + expect(packets[0].data[1].scrollDeltaY, equals(119.0)); + + // Because the delta is in increments of 120, but is similar to the + // previous event, it will be a trackpad event. + expect(packets[1].data[0].change, equals(ui.PointerChange.hover)); + expect( + packets[1].data[0].signalKind, equals(ui.PointerSignalKind.scroll)); + expect( + packets[1].data[0].kind, equals(ui.PointerDeviceKind.trackpad)); + expect(packets[1].data[0].pointerIdentifier, equals(0)); + expect(packets[1].data[0].synthesized, isFalse); + expect(packets[1].data[0].physicalX, equals(10.0 * dpi)); + expect(packets[1].data[0].physicalY, equals(10.0 * dpi)); + expect(packets[1].data[0].physicalDeltaX, equals(0.0)); + expect(packets[1].data[0].physicalDeltaY, equals(0.0)); + expect(packets[1].data[0].scrollDeltaX, equals(120.0)); + expect(packets[1].data[0].scrollDeltaY, equals(120.0)); + + // Because the delta is in increments of 120, but is again similar to the + // previous event, it will be a trackpad event. + expect(packets[2].data[0].change, equals(ui.PointerChange.hover)); + expect( + packets[2].data[0].signalKind, equals(ui.PointerSignalKind.scroll)); + expect( + packets[2].data[0].kind, equals(ui.PointerDeviceKind.trackpad)); + expect(packets[2].data[0].pointerIdentifier, equals(0)); + expect(packets[2].data[0].synthesized, isFalse); + expect(packets[2].data[0].physicalX, equals(10.0 * dpi)); + expect(packets[2].data[0].physicalY, equals(10.0 * dpi)); + expect(packets[2].data[0].physicalDeltaX, equals(0.0)); + expect(packets[2].data[0].physicalDeltaY, equals(0.0)); + expect(packets[2].data[0].scrollDeltaX, equals(120.0)); + expect(packets[2].data[0].scrollDeltaY, equals(120.0)); + + // Because the delta is not in increments of 120 and has matching wheelDelta, + // it will be a trackpad event. + expect(packets[3].data[0].change, equals(ui.PointerChange.hover)); + expect( + packets[3].data[0].signalKind, equals(ui.PointerSignalKind.scroll)); + expect( + packets[3].data[0].kind, equals(ui.PointerDeviceKind.trackpad)); + expect(packets[3].data[0].pointerIdentifier, equals(0)); + expect(packets[3].data[0].synthesized, isFalse); + expect(packets[3].data[0].physicalX, equals(10.0 * dpi)); + expect(packets[3].data[0].physicalY, equals(10.0 * dpi)); + expect(packets[3].data[0].physicalDeltaX, equals(0.0)); + expect(packets[3].data[0].physicalDeltaY, equals(0.0)); + expect(packets[3].data[0].scrollDeltaX, equals(119.0)); + expect(packets[3].data[0].scrollDeltaY, equals(119.0)); + + // Because the delta is in increments of 120, and is not similar to the + // previous event, but occured soon after the previous event, it will be + // a trackpad event. + expect(packets[4].data[0].change, equals(ui.PointerChange.hover)); + expect( + packets[4].data[0].signalKind, equals(ui.PointerSignalKind.scroll)); + expect( + packets[4].data[0].kind, equals(ui.PointerDeviceKind.trackpad)); + expect(packets[4].data[0].pointerIdentifier, equals(0)); + expect(packets[4].data[0].synthesized, isFalse); + expect(packets[4].data[0].physicalX, equals(10.0 * dpi)); + expect(packets[4].data[0].physicalY, equals(10.0 * dpi)); + expect(packets[4].data[0].physicalDeltaX, equals(0.0)); + expect(packets[4].data[0].physicalDeltaY, equals(0.0)); + expect(packets[4].data[0].scrollDeltaX, equals(-120.0)); + expect(packets[4].data[0].scrollDeltaY, equals(-120.0)); + + // Because the delta is in increments of 120, and is not similar to + // the previous event, and occured long after the previous event, it will be a mouse event. + expect(packets[5].data, hasLength(1)); + expect(packets[5].data[0].change, equals(ui.PointerChange.hover)); + expect( + packets[5].data[0].signalKind, equals(ui.PointerSignalKind.scroll)); + expect( + packets[5].data[0].kind, equals(ui.PointerDeviceKind.mouse)); + expect(packets[5].data[0].pointerIdentifier, equals(0)); + expect(packets[5].data[0].synthesized, isFalse); + expect(packets[5].data[0].physicalX, equals(10.0 * dpi)); + expect(packets[5].data[0].physicalY, equals(10.0 * dpi)); + expect(packets[5].data[0].physicalDeltaX, equals(0.0)); + expect(packets[5].data[0].physicalDeltaY, equals(0.0)); + expect(packets[5].data[0].scrollDeltaX, equals(0.0)); + expect(packets[5].data[0].scrollDeltaY, equals(-120.0)); + + // Because the delta is not in increments of 120 and has non-matching + // wheelDelta, it will be a mouse event. + expect(packets[6].data, hasLength(1)); + expect(packets[6].data[0].change, equals(ui.PointerChange.hover)); + expect( + packets[6].data[0].signalKind, equals(ui.PointerSignalKind.scroll)); + expect( + packets[6].data[0].kind, equals(ui.PointerDeviceKind.mouse)); + expect(packets[6].data[0].pointerIdentifier, equals(0)); + expect(packets[6].data[0].synthesized, isFalse); + expect(packets[6].data[0].physicalX, equals(10.0 * dpi)); + expect(packets[6].data[0].physicalY, equals(10.0 * dpi)); + expect(packets[6].data[0].physicalDeltaX, equals(0.0)); + expect(packets[6].data[0].physicalDeltaY, equals(0.0)); + expect(packets[6].data[0].scrollDeltaX, equals(0.0)); + expect(packets[6].data[0].scrollDeltaY, equals(40.0)); + }, + ); + _testEach<_ButtonedEventMixin>( <_ButtonedEventMixin>[ if (!isIosSafari) _PointerEventContext(), @@ -2854,6 +3077,9 @@ mixin _ButtonedEventMixin on _BasicEventContext { required double? clientY, required double? deltaX, required double? deltaY, + double? wheelDeltaX, + double? wheelDeltaY, + int? timeStamp, }) { final Function jsWheelEvent = js_util.getProperty(domWindow, 'WheelEvent'); final List eventArgs = [ @@ -2864,12 +3090,30 @@ mixin _ButtonedEventMixin on _BasicEventContext { 'clientY': clientY, 'deltaX': deltaX, 'deltaY': deltaY, + 'wheelDeltaX': wheelDeltaX, + 'wheelDeltaY': wheelDeltaY, } ]; - return js_util.callConstructor( + final DomEvent event = js_util.callConstructor( jsWheelEvent, js_util.jsify(eventArgs) as List, ); + // timeStamp can't be set in the constructor, need to override the getter. + if (timeStamp != null) { + js_util.callMethod( + objectConstructor, + 'defineProperty', + [ + event, + 'timeStamp', + js_util.jsify({ + 'value': timeStamp, + 'configurable': true + }) + ] + ); + } + return event; } }