Skip to content
This repository was archived by the owner on Feb 25, 2025. It is now read-only.
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
2 changes: 2 additions & 0 deletions lib/web_ui/lib/src/engine/dom.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
76 changes: 75 additions & 1 deletion lib/web_ui/lib/src/engine/pointer_binding.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -333,13 +335,83 @@ 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
Comment on lines +374 to +375
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

// 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<ui.PointerData> _convertWheelEventToPointerData(
DomWheelEvent event
) {
const int domDeltaPixel = 0x00;
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;
Expand Down Expand Up @@ -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,
Expand All @@ -382,6 +454,8 @@ mixin _WheelEventListenerMixin on _BaseAdapter {
scrollDeltaX: deltaX,
scrollDeltaY: deltaY,
);
_lastWheelEvent = event;
_lastWheelEventWasTrackpad = kind == ui.PointerDeviceKind.trackpad;
return data;
}

Expand Down
246 changes: 245 additions & 1 deletion lib/web_ui/test/engine/pointer_binding_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<ui.PointerDataPacket> packets = <ui.PointerDataPacket>[];
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(),
Expand Down Expand Up @@ -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<Function>(domWindow, 'WheelEvent');
final List<dynamic> eventArgs = <dynamic>[
Expand All @@ -2864,12 +3090,30 @@ mixin _ButtonedEventMixin on _BasicEventContext {
'clientY': clientY,
'deltaX': deltaX,
'deltaY': deltaY,
'wheelDeltaX': wheelDeltaX,
'wheelDeltaY': wheelDeltaY,
}
];
return js_util.callConstructor<DomEvent>(
final DomEvent event = js_util.callConstructor<DomEvent>(
jsWheelEvent,
js_util.jsify(eventArgs) as List<Object?>,
);
// timeStamp can't be set in the constructor, need to override the getter.
if (timeStamp != null) {
js_util.callMethod(
objectConstructor,
'defineProperty',
<dynamic>[
event,
'timeStamp',
js_util.jsify(<String, dynamic>{
'value': timeStamp,
'configurable': true
})
]
);
}
return event;
}
}

Expand Down