1// Copyright 2013 The Flutter Authors. All rights reserved. 2// Use of this source code is governed by a BSD-style license that can be 3// found in the LICENSE file. 4 5part of engine; 6 7/// Set this flag to true to see all the fired events in the console. 8const bool _debugLogPointerEvents = false; 9 10/// The signature of a callback that handles pointer events. 11typedef PointerDataCallback = void Function(List<ui.PointerData>); 12 13class PointerBinding { 14 /// The singleton instance of this object. 15 static PointerBinding get instance => _instance; 16 static PointerBinding _instance; 17 18 PointerBinding(this.domRenderer) { 19 if (_instance == null) { 20 _instance = this; 21 _detector = const PointerSupportDetector(); 22 _adapter = _createAdapter(); 23 } 24 assert(() { 25 registerHotRestartListener(() { 26 _adapter?.clearListeners(); 27 }); 28 return true; 29 }()); 30 } 31 32 final DomRenderer domRenderer; 33 PointerSupportDetector _detector; 34 BaseAdapter _adapter; 35 36 /// Should be used in tests to define custom detection of pointer support. 37 /// 38 /// ```dart 39 /// // Forces PointerBinding to use mouse events. 40 /// class MyTestDetector extends PointerSupportDetector { 41 /// @override 42 /// final bool hasPointerEvents = false; 43 /// 44 /// @override 45 /// final bool hasTouchEvents = false; 46 /// 47 /// @override 48 /// final bool hasMouseEvents = true; 49 /// } 50 /// 51 /// PointerBinding.instance.debugOverrideDetector(MyTestDetector()); 52 /// ``` 53 void debugOverrideDetector(PointerSupportDetector newDetector) { 54 newDetector ??= const PointerSupportDetector(); 55 // When changing the detector, we need to swap the adapter. 56 if (newDetector != _detector) { 57 _detector = newDetector; 58 _adapter?.clearListeners(); 59 _adapter = _createAdapter(); 60 } 61 } 62 63 BaseAdapter _createAdapter() { 64 if (_detector.hasPointerEvents) { 65 return PointerAdapter(_onPointerData, domRenderer); 66 } 67 if (_detector.hasTouchEvents) { 68 return TouchAdapter(_onPointerData, domRenderer); 69 } 70 if (_detector.hasMouseEvents) { 71 return MouseAdapter(_onPointerData, domRenderer); 72 } 73 return null; 74 } 75 76 void _onPointerData(List<ui.PointerData> data) { 77 final ui.PointerDataPacket packet = ui.PointerDataPacket(data: data); 78 ui.window.onPointerDataPacket(packet); 79 } 80} 81 82class PointerSupportDetector { 83 const PointerSupportDetector(); 84 85 bool get hasPointerEvents => js_util.hasProperty(html.window, 'PointerEvent'); 86 bool get hasTouchEvents => js_util.hasProperty(html.window, 'TouchEvent'); 87 bool get hasMouseEvents => js_util.hasProperty(html.window, 'MouseEvent'); 88 89 @override 90 String toString() => 91 'pointers:$hasPointerEvents, touch:$hasTouchEvents, mouse:$hasMouseEvents'; 92} 93 94/// Common functionality that's shared among adapters. 95abstract class BaseAdapter { 96 static final Map<String, html.EventListener> _listeners = 97 <String, html.EventListener>{}; 98 99 final DomRenderer domRenderer; 100 PointerDataCallback _callback; 101 Map<int, bool> _isDownMap = <int, bool>{}; 102 bool _isButtonDown(int button) { 103 return _isDownMap[button] == true; 104 } 105 106 void _updateButtonDownState(int button, bool value) { 107 _isDownMap[button] = value; 108 } 109 110 BaseAdapter(this._callback, this.domRenderer) { 111 _setup(); 112 } 113 114 /// Each subclass is expected to override this method to attach its own event 115 /// listeners and convert events into pointer events. 116 void _setup(); 117 118 /// Remove all active event listeners. 119 void clearListeners() { 120 final html.Element glassPane = domRenderer.glassPaneElement; 121 _listeners.forEach((String eventName, html.EventListener listener) { 122 glassPane.removeEventListener(eventName, listener); 123 }); 124 _listeners.clear(); 125 } 126 127 void _addEventListener(String eventName, html.EventListener handler) { 128 final html.EventListener loggedHandler = (html.Event event) { 129 if (_debugLogPointerEvents) { 130 print(event.type); 131 } 132 // Report the event to semantics. This information is used to debounce 133 // browser gestures. Semantics tells us whether it is safe to forward 134 // the event to the framework. 135 if (EngineSemanticsOwner.instance.receiveGlobalEvent(event)) { 136 handler(event); 137 } 138 }; 139 _listeners[eventName] = loggedHandler; 140 domRenderer.glassPaneElement 141 .addEventListener(eventName, loggedHandler, true); 142 } 143} 144 145const int _kPrimaryMouseButton = 0x1; 146const int _kSecondaryMouseButton = 0x2; 147 148int _pointerButtonFromHtmlEvent(html.Event event) { 149 if (event is html.PointerEvent) { 150 final html.PointerEvent pointerEvent = event; 151 return pointerEvent.button == 2 152 ? _kSecondaryMouseButton 153 : _kPrimaryMouseButton; 154 } else if (event is html.MouseEvent) { 155 final html.MouseEvent mouseEvent = event; 156 return mouseEvent.button == 2 157 ? _kSecondaryMouseButton 158 : _kPrimaryMouseButton; 159 } 160 return _kPrimaryMouseButton; 161} 162 163/// Adapter class to be used with browsers that support native pointer events. 164class PointerAdapter extends BaseAdapter { 165 PointerAdapter(PointerDataCallback callback, DomRenderer domRenderer) 166 : super(callback, domRenderer); 167 168 @override 169 void _setup() { 170 _addEventListener('pointerdown', (html.Event event) { 171 final int pointerButton = _pointerButtonFromHtmlEvent(event); 172 if (_isButtonDown(pointerButton)) { 173 // TODO(flutter_web): Remove this temporary fix for right click 174 // on web platform once context guesture is implemented. 175 _callback(_convertEventToPointerData(ui.PointerChange.up, event)); 176 } 177 _updateButtonDownState(pointerButton, true); 178 _callback(_convertEventToPointerData(ui.PointerChange.down, event)); 179 }); 180 181 _addEventListener('pointermove', (html.Event event) { 182 // TODO(flutter_web): During a drag operation pointermove will set 183 // button to -1 as opposed to mouse move which sets it to 2. 184 // This check is currently defaulting to primary button for now. 185 // Change this when context gesture is implemented in flutter framework. 186 if (!_isButtonDown(_pointerButtonFromHtmlEvent(event))) { 187 return; 188 } 189 _callback(_convertEventToPointerData(ui.PointerChange.move, event)); 190 }); 191 192 _addEventListener('pointerup', (html.Event event) { 193 // The pointer could have been released by a `pointerout` event, in which 194 // case `pointerup` should have no effect. 195 final int pointerButton = _pointerButtonFromHtmlEvent(event); 196 if (!_isButtonDown(pointerButton)) { 197 return; 198 } 199 _updateButtonDownState(pointerButton, false); 200 _callback(_convertEventToPointerData(ui.PointerChange.up, event)); 201 }); 202 203 // A browser fires cancel event if it concludes the pointer will no longer 204 // be able to generate events (example: device is deactivated) 205 _addEventListener('pointercancel', (html.Event event) { 206 _callback(_convertEventToPointerData(ui.PointerChange.cancel, event)); 207 }); 208 209 _addWheelEventListener((html.WheelEvent event) { 210 if (_debugLogPointerEvents) { 211 print(event.type); 212 } 213 _callback(_convertWheelEventToPointerData(event)); 214 // Prevent default so mouse wheel event doesn't get converted to 215 // a scroll event that semantic nodes would process. 216 event.preventDefault(); 217 }); 218 } 219 220 List<ui.PointerData> _convertEventToPointerData( 221 ui.PointerChange change, 222 html.PointerEvent evt, 223 ) { 224 final List<html.PointerEvent> allEvents = _expandEvents(evt); 225 final List<ui.PointerData> data = List<ui.PointerData>(allEvents.length); 226 for (int i = 0; i < allEvents.length; i++) { 227 final html.PointerEvent event = allEvents[i]; 228 data[i] = ui.PointerData( 229 change: change, 230 timeStamp: _eventTimeStampToDuration(event.timeStamp), 231 kind: _pointerTypeToDeviceKind(event.pointerType), 232 device: event.pointerId, 233 physicalX: event.client.x, 234 physicalY: event.client.y, 235 buttons: event.buttons, 236 pressure: event.pressure, 237 pressureMin: 0.0, 238 pressureMax: 1.0, 239 tilt: _computeHighestTilt(event), 240 ); 241 } 242 return data; 243 } 244 245 List<html.PointerEvent> _expandEvents(html.PointerEvent event) { 246 // For browsers that don't support `getCoalescedEvents`, we fallback to 247 // using the original event. 248 if (js_util.hasProperty(event, 'getCoalescedEvents')) { 249 final List<html.PointerEvent> coalescedEvents = 250 event.getCoalescedEvents(); 251 // Some events don't perform coalescing, so they return an empty list. In 252 // that case, we also fallback to using the original event. 253 if (coalescedEvents.isNotEmpty) { 254 return coalescedEvents; 255 } 256 } 257 return <html.PointerEvent>[event]; 258 } 259 260 ui.PointerDeviceKind _pointerTypeToDeviceKind(String pointerType) { 261 switch (pointerType) { 262 case 'mouse': 263 return ui.PointerDeviceKind.mouse; 264 case 'pen': 265 return ui.PointerDeviceKind.stylus; 266 case 'touch': 267 return ui.PointerDeviceKind.touch; 268 default: 269 return ui.PointerDeviceKind.unknown; 270 } 271 } 272 273 /// Tilt angle is -90 to + 90. Take maximum deflection and convert to radians. 274 double _computeHighestTilt(html.PointerEvent e) => 275 (e.tiltX.abs() > e.tiltY.abs() ? e.tiltX : e.tiltY).toDouble() / 276 180.0 * 277 math.pi; 278} 279 280/// Adapter to be used with browsers that support touch events. 281class TouchAdapter extends BaseAdapter { 282 TouchAdapter(PointerDataCallback callback, DomRenderer domRenderer) 283 : super(callback, domRenderer); 284 285 @override 286 void _setup() { 287 _addEventListener('touchstart', (html.Event event) { 288 _updateButtonDownState(_kPrimaryMouseButton, true); 289 _callback(_convertEventToPointerData(ui.PointerChange.down, event)); 290 }); 291 292 _addEventListener('touchmove', (html.Event event) { 293 event.preventDefault(); // Prevents standard overscroll on iOS/Webkit. 294 if (!_isButtonDown(_kPrimaryMouseButton)) { 295 return; 296 } 297 _callback(_convertEventToPointerData(ui.PointerChange.move, event)); 298 }); 299 300 _addEventListener('touchend', (html.Event event) { 301 _updateButtonDownState(_kPrimaryMouseButton, false); 302 _callback(_convertEventToPointerData(ui.PointerChange.up, event)); 303 }); 304 305 _addEventListener('touchcancel', (html.Event event) { 306 _callback(_convertEventToPointerData(ui.PointerChange.cancel, event)); 307 }); 308 } 309 310 List<ui.PointerData> _convertEventToPointerData( 311 ui.PointerChange change, 312 html.TouchEvent event, 313 ) { 314 final html.TouchList touches = event.changedTouches; 315 final List<ui.PointerData> data = List<ui.PointerData>(touches.length); 316 final int len = touches.length; 317 for (int i = 0; i < len; i++) { 318 final html.Touch touch = touches[i]; 319 data[i] = ui.PointerData( 320 change: change, 321 timeStamp: _eventTimeStampToDuration(event.timeStamp), 322 kind: ui.PointerDeviceKind.touch, 323 signalKind: ui.PointerSignalKind.none, 324 device: touch.identifier, 325 physicalX: touch.client.x, 326 physicalY: touch.client.y, 327 pressure: 1.0, 328 pressureMin: 0.0, 329 pressureMax: 1.0, 330 ); 331 } 332 333 return data; 334 } 335} 336 337/// Intentionally set to -1 so it doesn't conflict with other device IDs. 338const int _mouseDeviceId = -1; 339 340/// Adapter to be used with browsers that support mouse events. 341class MouseAdapter extends BaseAdapter { 342 MouseAdapter(PointerDataCallback callback, DomRenderer domRenderer) 343 : super(callback, domRenderer); 344 345 @override 346 void _setup() { 347 _addEventListener('mousedown', (html.Event event) { 348 final int pointerButton = _pointerButtonFromHtmlEvent(event); 349 if (_isButtonDown(pointerButton)) { 350 // TODO(flutter_web): Remove this temporary fix for right click 351 // on web platform once context guesture is implemented. 352 _callback(_convertEventToPointerData(ui.PointerChange.up, event)); 353 } 354 _updateButtonDownState(pointerButton, true); 355 _callback(_convertEventToPointerData(ui.PointerChange.down, event)); 356 }); 357 358 _addEventListener('mousemove', (html.Event event) { 359 if (!_isButtonDown(_pointerButtonFromHtmlEvent(event))) { 360 return; 361 } 362 _callback(_convertEventToPointerData(ui.PointerChange.move, event)); 363 }); 364 365 _addEventListener('mouseup', (html.Event event) { 366 _updateButtonDownState(_pointerButtonFromHtmlEvent(event), false); 367 _callback(_convertEventToPointerData(ui.PointerChange.up, event)); 368 }); 369 370 _addWheelEventListener((html.WheelEvent event) { 371 if (_debugLogPointerEvents) { 372 print(event.type); 373 } 374 _callback(_convertWheelEventToPointerData(event)); 375 event.preventDefault(); 376 }); 377 } 378 379 List<ui.PointerData> _convertEventToPointerData( 380 ui.PointerChange change, 381 html.MouseEvent event, 382 ) { 383 return <ui.PointerData>[ 384 ui.PointerData( 385 change: change, 386 timeStamp: _eventTimeStampToDuration(event.timeStamp), 387 kind: ui.PointerDeviceKind.mouse, 388 signalKind: ui.PointerSignalKind.none, 389 device: _mouseDeviceId, 390 physicalX: event.client.x, 391 physicalY: event.client.y, 392 buttons: event.buttons, 393 pressure: 1.0, 394 pressureMin: 0.0, 395 pressureMax: 1.0, 396 ) 397 ]; 398 } 399} 400 401/// Convert a floating number timestamp (in milliseconds) to a [Duration] by 402/// splitting it into two integer components: milliseconds + microseconds. 403Duration _eventTimeStampToDuration(num milliseconds) { 404 final int ms = milliseconds.toInt(); 405 final int micro = 406 ((milliseconds - ms) * Duration.microsecondsPerMillisecond).toInt(); 407 return Duration(milliseconds: ms, microseconds: micro); 408} 409 410bool _isWheelDeviceAdded = false; 411 412List<ui.PointerData> _convertWheelEventToPointerData( 413 html.WheelEvent event, 414) { 415 const int domDeltaPixel = 0x00; 416 const int domDeltaLine = 0x01; 417 const int domDeltaPage = 0x02; 418 419 // Flutter only supports pixel scroll delta. Convert deltaMode values 420 // to pixels. 421 double deltaX = event.deltaX; 422 double deltaY = event.deltaY; 423 switch (event.deltaMode) { 424 case domDeltaLine: 425 deltaX *= 32.0; 426 deltaY *= 32.0; 427 break; 428 case domDeltaPage: 429 deltaX *= ui.window.physicalSize.width; 430 deltaY *= ui.window.physicalSize.height; 431 break; 432 case domDeltaPixel: 433 default: 434 break; 435 } 436 437 final List<ui.PointerData> data = <ui.PointerData>[]; 438 // Only send [PointerChange.add] the first time. 439 if (!_isWheelDeviceAdded) { 440 _isWheelDeviceAdded = true; 441 data.add(ui.PointerData( 442 change: ui.PointerChange.add, 443 timeStamp: _eventTimeStampToDuration(event.timeStamp), 444 kind: ui.PointerDeviceKind.mouse, 445 // In order for Flutter to actually add this pointer, we need to set the 446 // signal to none. 447 signalKind: ui.PointerSignalKind.none, 448 device: _mouseDeviceId, 449 physicalX: event.client.x, 450 physicalY: event.client.y, 451 buttons: event.buttons, 452 pressure: 1.0, 453 pressureMin: 0.0, 454 pressureMax: 1.0, 455 scrollDeltaX: deltaX, 456 scrollDeltaY: deltaY, 457 )); 458 } 459 data.add(ui.PointerData( 460 change: ui.PointerChange.hover, 461 timeStamp: _eventTimeStampToDuration(event.timeStamp), 462 kind: ui.PointerDeviceKind.mouse, 463 signalKind: ui.PointerSignalKind.scroll, 464 device: _mouseDeviceId, 465 physicalX: event.client.x, 466 physicalY: event.client.y, 467 buttons: event.buttons, 468 pressure: 1.0, 469 pressureMin: 0.0, 470 pressureMax: 1.0, 471 scrollDeltaX: deltaX, 472 scrollDeltaY: deltaY, 473 )); 474 return data; 475} 476 477void _addWheelEventListener(void listener(html.WheelEvent e)) { 478 final dynamic eventOptions = js_util.newObject(); 479 js_util.setProperty(eventOptions, 'passive', false); 480 js_util.callMethod(PointerBinding.instance.domRenderer.glassPaneElement, 481 'addEventListener', <dynamic>[ 482 'wheel', 483 js.allowInterop((html.WheelEvent event) => listener(event)), 484 eventOptions 485 ]); 486} 487