[web] Fix drag failure when RMB pointer up event is not received (#22946)

diff --git a/lib/web_ui/lib/src/engine/pointer_binding.dart b/lib/web_ui/lib/src/engine/pointer_binding.dart
index 74f21a5..b4edd40 100644
--- a/lib/web_ui/lib/src/engine/pointer_binding.dart
+++ b/lib/web_ui/lib/src/engine/pointer_binding.dart
@@ -199,7 +199,13 @@
       }
 
       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 @@
     }
 
     _pressedButtons = _inferDownFlutterButtons(button, buttons);
+
     return _SanitizedDetails(
       change: ui.PointerChange.down,
       buttons: _pressedButtons,
@@ -389,18 +396,6 @@
 
   _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 @@
     );
   }
 
+  _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 @@
 }
 
 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 @@
     _addPointerEventListener('pointerdown', (html.PointerEvent event) {
       final int device = event.pointerId!;
       final List<ui.PointerData> pointerData = <ui.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 @@
       final int device = event.pointerId!;
       final _ButtonSanitizer sanitizer = _ensureSanitizer(device);
       final List<ui.PointerData> pointerData = <ui.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 @@
     });
   }
 
+  // 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<ui.PointerData> 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 @@
   void setup() {
     _addMouseEventListener('mousedown', (html.MouseEvent event) {
       final List<ui.PointerData> pointerData = <ui.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 @@
 
     _addMouseEventListener('mousemove', (html.MouseEvent event) {
       final List<ui.PointerData> pointerData = <ui.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 @@
       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 @@
     });
   }
 
+  // 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<ui.PointerData> 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/lib/web_ui/lib/src/engine/pointer_converter.dart b/lib/web_ui/lib/src/engine/pointer_converter.dart
index c76e1a8..26b72a0 100644
--- a/lib/web_ui/lib/src/engine/pointer_converter.dart
+++ b/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 @@
     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/lib/web_ui/test/engine/pointer_binding_test.dart b/lib/web_ui/test/engine/pointer_binding_test.dart
index 4c100b9..693f43c 100644
--- a/lib/web_ui/test/engine/pointer_binding_test.dart
+++ b/lib/web_ui/test/engine/pointer_binding_test.dart
@@ -1227,6 +1227,57 @@
   _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<ui.PointerDataPacket> packets = <ui.PointerDataPacket>[];
+      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(),
       _MouseEventContext(),
     ],
     'handles RMB click when the browser sends it as a move',
@@ -1303,10 +1354,16 @@
         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 @@
 
       // 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 @@
         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.