diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/pointer_binding.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/pointer_binding.dart index 74f21a5b65f..b4edd401fe2 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/pointer_binding.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/pointer_binding.dart @@ -199,7 +199,13 @@ abstract class _BaseAdapter { } if (_debugLogPointerEvents) { - print(event.type); + if (event is html.PointerEvent) { + print('${event.type} ' + '${event.client.x.toStringAsFixed(1)},' + '${event.client.y.toStringAsFixed(1)}'); + } else { + print(event.type); + } } // Report the event to semantics. This information is used to debounce // browser gestures. Semantics tells us whether it is safe to forward @@ -381,6 +387,7 @@ class _ButtonSanitizer { } _pressedButtons = _inferDownFlutterButtons(button, buttons); + return _SanitizedDetails( change: ui.PointerChange.down, buttons: _pressedButtons, @@ -389,18 +396,6 @@ class _ButtonSanitizer { _SanitizedDetails sanitizeMoveEvent({required int buttons}) { final int newPressedButtons = _htmlButtonsToFlutterButtons(buttons); - // This could happen when the context menu is active and the user clicks - // RMB somewhere else. The browser sends a down event with `buttons:0`. - // - // In this case, we keep the old `buttons` value so we don't confuse the - // framework. - if (_pressedButtons != 0 && newPressedButtons == 0) { - return _SanitizedDetails( - change: ui.PointerChange.move, - buttons: _pressedButtons, - ); - } - // This could happen when the user clicks RMB then moves the mouse quickly. // The brower sends a move event with `buttons:2` even though there's no // buttons down yet. @@ -434,6 +429,30 @@ class _ButtonSanitizer { ); } + _SanitizedDetails? sanitizeUpEventWithButtons({required int buttons}) { + final int newPressedButtons = _htmlButtonsToFlutterButtons(buttons); + // This could happen when the context menu is active and the user clicks + // RMB somewhere else. The browser sends a down event with `buttons:0`. + // + // In this case, we keep the old `buttons` value so we don't confuse the + // framework. + if (_pressedButtons != 0 && newPressedButtons == 0) { + return _SanitizedDetails( + change: ui.PointerChange.move, + buttons: _pressedButtons, + ); + } + + _pressedButtons = newPressedButtons; + + return _SanitizedDetails( + change: _pressedButtons == 0 + ? ui.PointerChange.hover + : ui.PointerChange.move, + buttons: _pressedButtons, + ); + } + _SanitizedDetails sanitizeCancelEvent() { _pressedButtons = 0; return _SanitizedDetails( @@ -444,6 +463,7 @@ class _ButtonSanitizer { } typedef _PointerEventListener = dynamic Function(html.PointerEvent event); +const int kContextMenuButton = 2; /// Adapter class to be used with browsers that support native pointer events. /// @@ -492,8 +512,16 @@ class _PointerAdapter extends _BaseAdapter with _WheelEventListenerMixin { _addPointerEventListener('pointerdown', (html.PointerEvent event) { final int device = event.pointerId!; final List pointerData = []; + final _ButtonSanitizer sanitizer = _ensureSanitizer(device); + if (event.button == kContextMenuButton) { + _handleMissingRightMouseUpEvent(sanitizer, + sanitizer._pressedButtons, + sanitizer._pressedButtons & ~kContextMenuButton, + event, + pointerData); + } final _SanitizedDetails details = - _ensureSanitizer(device).sanitizeDownEvent( + sanitizer.sanitizeDownEvent( button: event.button, buttons: event.buttons!, ); @@ -505,9 +533,19 @@ class _PointerAdapter extends _BaseAdapter with _WheelEventListenerMixin { final int device = event.pointerId!; final _ButtonSanitizer sanitizer = _ensureSanitizer(device); final List pointerData = []; + final int buttonsBeforeEvent = sanitizer._pressedButtons; final Iterable<_SanitizedDetails> detailsList = _expandEvents(event).map( - (html.PointerEvent expandedEvent) => sanitizer.sanitizeMoveEvent(buttons: expandedEvent.buttons!), + (html.PointerEvent expandedEvent) { + return sanitizer.sanitizeMoveEvent(buttons: expandedEvent.buttons!); + }, ); + _handleMissingRightMouseUpEvent( + sanitizer, + buttonsBeforeEvent, + (sanitizer._inferDownFlutterButtons(event.button, event.buttons!) + & kContextMenuButton), + event, + pointerData); for (_SanitizedDetails details in detailsList) { _convertEventsToPointerData(data: pointerData, event: event, details: details); } @@ -541,6 +579,39 @@ class _PointerAdapter extends _BaseAdapter with _WheelEventListenerMixin { }); } + // Handle special case where right mouse button no longer is pressed. + // We need to synthesize right mouse up, otherwise drag gesture will fail + // to complete or multiple RMB down events will lead to wrong state. + void _handleMissingRightMouseUpEvent(_ButtonSanitizer sanitizer, + int buttonsBeforeEvent, int buttonsAfterEvent, html.PointerEvent event, + List pointerData) { + if ((buttonsBeforeEvent & kContextMenuButton) != 0 && + buttonsAfterEvent == 0) { + final ui.PointerDeviceKind kind = + _pointerTypeToDeviceKind(event.pointerType!); + final int device = kind == ui.PointerDeviceKind.mouse + ? _mouseDeviceId : event.pointerId!; + final double tilt = _computeHighestTilt(event); + final Duration timeStamp = _BaseAdapter._eventTimeStampToDuration(event.timeStamp!); + sanitizer._pressedButtons &= ~kContextMenuButton; + _pointerDataConverter.convert( + pointerData, + change: ui.PointerChange.up, + timeStamp: timeStamp, + kind: kind, + signalKind: ui.PointerSignalKind.none, + device: device, + physicalX: event.client.x.toDouble() * ui.window.devicePixelRatio, + physicalY: event.client.y.toDouble() * ui.window.devicePixelRatio, + buttons: sanitizer._pressedButtons, + pressure: event.pressure as double, + pressureMin: 0.0, + pressureMax: 1.0, + tilt: tilt, + ); + } + } + // For each event that is de-coalesced from `event` and described in // `details`, convert it to pointer data and store in `data`. void _convertEventsToPointerData({ @@ -782,6 +853,13 @@ class _MouseAdapter extends _BaseAdapter with _WheelEventListenerMixin { void setup() { _addMouseEventListener('mousedown', (html.MouseEvent event) { final List pointerData = []; + if (event.button == kContextMenuButton) { + _handleMissingRightMouseUpEvent(_sanitizer, + _sanitizer._pressedButtons, + _sanitizer._pressedButtons & ~kContextMenuButton, + event, + pointerData); + } final _SanitizedDetails sanitizedDetails = _sanitizer.sanitizeDownEvent( button: event.button, @@ -793,6 +871,14 @@ class _MouseAdapter extends _BaseAdapter with _WheelEventListenerMixin { _addMouseEventListener('mousemove', (html.MouseEvent event) { final List pointerData = []; + final int buttonsBeforeEvent = _sanitizer._pressedButtons; + _handleMissingRightMouseUpEvent( + _sanitizer, + buttonsBeforeEvent, + (_sanitizer._inferDownFlutterButtons(event.button, event.buttons!) + & kContextMenuButton), + event, + pointerData); final _SanitizedDetails sanitizedDetails = _sanitizer.sanitizeMoveEvent(buttons: event.buttons!); _convertEventsToPointerData(data: pointerData, event: event, details: sanitizedDetails); _callback(pointerData); @@ -803,7 +889,7 @@ class _MouseAdapter extends _BaseAdapter with _WheelEventListenerMixin { final bool isEndOfDrag = event.buttons == 0; final _SanitizedDetails sanitizedDetails = isEndOfDrag ? _sanitizer.sanitizeUpEvent()! : - _sanitizer.sanitizeMoveEvent(buttons: event.buttons!); + _sanitizer.sanitizeUpEventWithButtons(buttons: event.buttons!)!; _convertEventsToPointerData(data: pointerData, event: event, details: sanitizedDetails); _callback(pointerData); }, acceptOutsideGlasspane: true); @@ -813,6 +899,32 @@ class _MouseAdapter extends _BaseAdapter with _WheelEventListenerMixin { }); } + // Handle special case where right mouse button no longer is pressed. + // We need to synthesize right mouse up, otherwise drag gesture will fail + // to complete or multiple RMB down events will lead to wrong state. + void _handleMissingRightMouseUpEvent(_ButtonSanitizer sanitizer, + int buttonsBeforeEvent, int buttonsAfterEvent, html.MouseEvent event, + List pointerData) { + if ((buttonsBeforeEvent & kContextMenuButton) != 0 && + buttonsAfterEvent == 0) { + sanitizer._pressedButtons &= ~2; + _pointerDataConverter.convert( + pointerData, + change: ui.PointerChange.up, + timeStamp: _BaseAdapter._eventTimeStampToDuration(event.timeStamp!), + kind: ui.PointerDeviceKind.mouse, + signalKind: ui.PointerSignalKind.none, + device: _mouseDeviceId, + physicalX: event.client.x.toDouble() * ui.window.devicePixelRatio, + physicalY: event.client.y.toDouble() * ui.window.devicePixelRatio, + buttons: _sanitizer._pressedButtons, + pressure: 1.0, + pressureMin: 0.0, + pressureMax: 1.0, + ); + } + } + // For each event that is de-coalesced from `event` and described in // `detailsList`, convert it to pointer data and store in `data`. void _convertEventsToPointerData({ diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/pointer_converter.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/pointer_converter.dart index c76e1a8e517..26b72a08f9b 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/pointer_converter.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/pointer_converter.dart @@ -5,6 +5,8 @@ // @dart = 2.12 part of engine; +const bool _debugLogPointerConverter = false; + class _PointerState { _PointerState(this.x, this.y); @@ -237,6 +239,9 @@ class PointerDataConverter { double scrollDeltaX = 0.0, double scrollDeltaY = 0.0, }) { + if (_debugLogPointerConverter) { + print('>> device=$device change = $change buttons = $buttons'); + } assert(change != null); // ignore: unnecessary_null_comparison if (signalKind == null || signalKind == ui.PointerSignalKind.none) { diff --git a/engine/src/flutter/lib/web_ui/test/engine/pointer_binding_test.dart b/engine/src/flutter/lib/web_ui/test/engine/pointer_binding_test.dart index 4c100b91e11..693f43cc349 100644 --- a/engine/src/flutter/lib/web_ui/test/engine/pointer_binding_test.dart +++ b/engine/src/flutter/lib/web_ui/test/engine/pointer_binding_test.dart @@ -1224,6 +1224,57 @@ void testMain() { }, ); + _testEach<_ButtonedEventMixin>( + [ + _PointerEventContext(), + ], + 'correctly handles missing right mouse button up when followed by move', + (_ButtonedEventMixin context) { + PointerBinding.instance.debugOverrideDetector(context); + // This can happen with the following gesture sequence: + // + // - Pops up the context menu by right clicking; + // - Clicks LMB to close context menu. + // - Moves mouse. + + List packets = []; + ui.window.onPointerDataPacket = (ui.PointerDataPacket packet) { + packets.add(packet); + }; + + // Press RMB popping up the context menu, then release by LMB down and up. + // Browser won't send up event in that case. + glassPane.dispatchEvent(context.mouseDown( + button: 2, + buttons: 2, + )); + expect(packets, hasLength(1)); + expect(packets[0].data, hasLength(2)); + expect(packets[0].data[0].change, equals(ui.PointerChange.add)); + expect(packets[0].data[0].synthesized, equals(true)); + + expect(packets[0].data[1].change, equals(ui.PointerChange.down)); + expect(packets[0].data[1].synthesized, equals(false)); + expect(packets[0].data[1].buttons, equals(2)); + packets.clear(); + + // User now hovers. + glassPane.dispatchEvent(context.mouseMove( + button: _kNoButtonChange, + buttons: 0, + )); + expect(packets, hasLength(1)); + expect(packets[0].data, hasLength(2)); + expect(packets[0].data[0].change, equals(ui.PointerChange.up)); + expect(packets[0].data[0].synthesized, equals(false)); + expect(packets[0].data[0].buttons, equals(0)); + expect(packets[0].data[1].change, equals(ui.PointerChange.hover)); + expect(packets[0].data[1].synthesized, equals(false)); + expect(packets[0].data[1].buttons, equals(0)); + packets.clear(); + }, + ); + _testEach<_ButtonedEventMixin>( [ _PointerEventContext(), @@ -1303,10 +1354,16 @@ void testMain() { clientY: 20.0, )); expect(packets, hasLength(1)); - expect(packets[0].data, hasLength(1)); + expect(packets[0].data, hasLength(3)); expect(packets[0].data[0].change, equals(ui.PointerChange.move)); - expect(packets[0].data[0].synthesized, equals(false)); + expect(packets[0].data[0].synthesized, equals(true)); expect(packets[0].data[0].buttons, equals(2)); + expect(packets[0].data[1].change, equals(ui.PointerChange.up)); + expect(packets[0].data[1].synthesized, equals(false)); + expect(packets[0].data[1].buttons, equals(0)); + expect(packets[0].data[2].change, equals(ui.PointerChange.hover)); + expect(packets[0].data[2].synthesized, equals(false)); + expect(packets[0].data[2].buttons, equals(0)); packets.clear(); }, ); @@ -1416,7 +1473,7 @@ void testMain() { // Press RMB again. In Chrome, when RMB is clicked again while the // context menu is still active, it sends a pointerdown/mousedown event - // with "buttons:0". + // with "buttons:0". We convert this to pointer up, pointer down. glassPane.dispatchEvent(context.mouseDown( button: 2, buttons: 0, @@ -1424,10 +1481,16 @@ void testMain() { clientY: 20.0, )); expect(packets, hasLength(1)); - expect(packets[0].data, hasLength(1)); + expect(packets[0].data, hasLength(3)); expect(packets[0].data[0].change, equals(ui.PointerChange.move)); - expect(packets[0].data[0].synthesized, equals(false)); + expect(packets[0].data[0].synthesized, equals(true)); expect(packets[0].data[0].buttons, equals(2)); + expect(packets[0].data[1].change, equals(ui.PointerChange.up)); + expect(packets[0].data[1].synthesized, equals(false)); + expect(packets[0].data[1].buttons, equals(0)); + expect(packets[0].data[2].change, equals(ui.PointerChange.down)); + expect(packets[0].data[2].synthesized, equals(false)); + expect(packets[0].data[2].buttons, equals(2)); packets.clear(); // Release RMB.