blob: 03ca36bdadf5474b824ae2d291f3d52a2e8bc8c6 [file] [log] [blame]
// Copyright (c) 2023, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
import 'dart:async';
import 'dart:js_interop';
import '../../dom.dart' as html;
import '../../helpers.dart' show Device;
/// Helper class used to create streams abstracting DOM events. This is a
/// piece of the helper layer directly derived from a similar feature in
/// `dart:html`.
///
/// A few differences compared to `dart:html`:
/// * The helper layer doesn't have `ElementList` APIs, so this
/// provider omits APIs related to them.
///
/// * Streams returned here behave slighly differently. The timing of when
/// callbacks execute is sometimes different when using stream to future APIs
/// like `.first`. In particular, when using synchronous browser APIs like
/// `dispatchEvent`, the Dart callbacks that would have executed
/// synchronously in `dart:html`, may now execute asynchronously. This only
/// breaks code that relied on specific timing details of the
/// implementation, but at an API level, the change is non breaking.
class EventStreamProvider<T extends html.Event> {
final String _eventType;
const EventStreamProvider(this._eventType);
/// Gets a [Stream] for this event type, on the specified target.
///
/// This will always return a broadcast stream so multiple listeners can be
/// used simultaneously.
///
/// This may be used to capture DOM events:
///
/// Element.keyDownEvent.forTarget(element, useCapture: true).listen(...);
///
/// // Alternate method:
/// Element.keyDownEvent.forTarget(element).capture(...);
///
/// Or for listening to an event which will bubble through the DOM tree:
///
/// MediaElement.pauseEvent.forTarget(document.body).listen(...);
///
/// See also:
///
/// * [EventTarget.addEventListener](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener)
/// from MDN.
Stream<T> forTarget(html.EventTarget? e, {bool useCapture = false}) =>
_EventStream<T>(e, _eventType, useCapture);
/// Gets a [Stream] for this event type, on the specified element.
///
/// This will always return a broadcast stream so multiple listeners can be
/// used simultaneously.
///
/// This may be used to capture DOM events:
///
/// Element.keyDownEvent.forElement(element, useCapture: true)
/// .listen(...);
///
/// // Alternate method:
/// Element.keyDownEvent.forElement(element).capture(...);
///
/// Or for listening to an event which will bubble through the DOM tree:
///
/// MediaElement.pauseEvent.forElement(document.body).listen(...);
///
/// See also:
///
/// * [EventTarget.addEventListener](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener)
/// from MDN.
ElementStream<T> forElement(html.Element e, {bool useCapture = false}) =>
_ElementEventStreamImpl<T>(e, _eventType, useCapture);
/// Gets the type of the event which this would listen for on the specified
/// event target.
///
/// The target is necessary because some browsers may use different event
/// names for the same purpose and the target allows differentiating browser
/// support.
String getEventType(html.EventTarget target) => _eventType;
}
/// A stream that allows capturing events on a parent element.
///
/// Note, unlike `dart:html`'s `ElementStream`, this implementation doesn't
/// provide the jquery style `matches` filtering functionality.
abstract class ElementStream<T extends html.Event> implements Stream<T> {
/// Adds a capturing subscription to this stream.
///
/// If the target of the event is a descendant of the element from which this
/// stream derives then [onData] is called before the event propagates down to
/// the target. This is the opposite of bubbling behavior, where the event
/// is first processed for the event target and then bubbles upward.
///
/// ## Other resources
///
/// * [Event Capture](http://www.w3.org/TR/DOM-Level-2-Events/events.html#Events-flow-capture)
/// from the W3C DOM Events specification.
StreamSubscription<T> capture(void Function(T) onData);
}
/// Adapter for exposing DOM events as Dart streams.
class _EventStream<T extends html.Event> extends Stream<T> {
final html.EventTarget? _target;
final String _eventType;
final bool _useCapture;
_EventStream(this._target, this._eventType, this._useCapture);
// DOM events are inherently multi-subscribers.
@override
Stream<T> asBroadcastStream(
{void Function(StreamSubscription<T>)? onListen,
void Function(StreamSubscription<T>)? onCancel}) =>
this;
@override
bool get isBroadcast => true;
// TODO(9757): Inlining should be smart and inline only when inlining would
// enable scalar replacement of an immediately allocated receiver.
@pragma('dart2js:tryInline')
@override
StreamSubscription<T> listen(void Function(T)? onData,
{Function? onError, void Function()? onDone, bool? cancelOnError}) =>
_EventStreamSubscription<T>(_target, _eventType, onData, _useCapture);
}
/// Adapter for exposing DOM Element events as streams
class _ElementEventStreamImpl<T extends html.Event> extends _EventStream<T>
implements ElementStream<T> {
_ElementEventStreamImpl(super.target, super.eventType, super.useCapture);
@override
StreamSubscription<T> capture(void Function(T) onData) =>
_EventStreamSubscription<T>(_target, _eventType, onData, true);
}
class _EventStreamSubscription<T extends html.Event>
implements StreamSubscription<T> {
int _pauseCount = 0;
html.EventTarget? _target;
final String _eventType;
html.EventListener? _onData;
final bool _useCapture;
// TODO(leafp): It would be better to write this as
// _onData = onData == null ? null :
// onData is void Function(Event)
// ? _wrapZone<Event>(onData)
// : _wrapZone<Event>((e) => onData(e as T))
// In order to support existing tests which pass the wrong type of events but
// use a more general listener, without causing as much slowdown for things
// which are typed correctly. But this currently runs afoul of restrictions
// on is checks for compatibility with the VM.
_EventStreamSubscription(
this._target, this._eventType, void Function(T)? onData, this._useCapture)
: _onData = onData == null
? null
// ignore: avoid_dynamic_calls
: _wrapZone<html.Event>((e) => (onData as dynamic)(e))?.toJS {
_tryResume();
}
@override
Future<void> cancel() {
// Note: the use of `emptyFuture` here has an indirect effect. The timing of
// when stream listeners get dispatched is different from that of
// `dart:html`.
//
// For example, the following program prints 1, 2, 3, 4, but with
// `dart:html` it would have printed 1, 2, 4, 3
//
// ```dart
// import 'package:web/web.dart';
//
// void main() {
// print('1');
// final body = document.body!;
// body.onTouchStart.first.whenComplete(() {
// print('4');
// });
//
// print('2');
// body.dispatchEvent(TouchEvent('touchstart'));
// print('3');
// }
// ```
//
// More details can be found on [this change][1].
// [1]: https://dart-review.googlesource.com/c/sdk/+/175323
final emptyFuture = Future<void>.value();
if (_canceled) return emptyFuture;
_unlisten();
// Clear out the target to indicate this is complete.
_target = null;
_onData = null;
return emptyFuture;
}
bool get _canceled => _target == null;
@override
void onData(void Function(T)? handleData) {
if (_canceled) {
throw StateError('Subscription has been canceled.');
}
// Remove current event listener.
_unlisten();
_onData = handleData == null
? null
// ignore: avoid_dynamic_calls
: _wrapZone<html.Event>((e) => (handleData as dynamic)(e))?.toJS;
_tryResume();
}
/// Has no effect.
@override
void onError(Function? handleError) {}
/// Has no effect.
@override
void onDone(void Function()? handleDone) {}
@override
void pause([Future<dynamic>? resumeSignal]) {
if (_canceled) return;
++_pauseCount;
_unlisten();
if (resumeSignal != null) {
resumeSignal.whenComplete(resume);
}
}
@override
bool get isPaused => _pauseCount > 0;
@override
void resume() {
if (_canceled || !isPaused) return;
--_pauseCount;
_tryResume();
}
void _tryResume() {
if (_onData != null && !isPaused) {
_target!.addEventListener(_eventType, _onData, _useCapture.toJS);
}
}
void _unlisten() {
if (_onData != null) {
_target!.removeEventListener(_eventType, _onData, _useCapture.toJS);
}
}
@override
Future<E> asFuture<E>([E? futureValue]) =>
// We just need a future that will never succeed or fail.
Completer<E>().future;
}
/// Base class that supports listening for and dispatching browser events.
///
/// Normally events are accessed via the Stream getter:
///
/// element.onMouseOver.listen((e) => print('Mouse over!'));
///
/// To access bubbling events which are declared on one element, but may bubble
/// up to another element type (common for MediaElement events):
///
/// MediaElement.pauseEvent.forTarget(document.body).listen(...);
///
/// To useCapture on events:
///
/// Element.keyDownEvent.forTarget(element, useCapture: true).listen(...);
///
/// Custom events can be declared as:
///
/// class DataGenerator {
/// static EventStreamProvider<Event> dataEvent =
/// new EventStreamProvider('data');
/// }
///
/// Then listeners should access the event with:
///
/// DataGenerator.dataEvent.forTarget(element).listen(...);
///
/// Custom events can also be accessed as:
///
/// element.on['some_event'].listen(...);
///
/// This approach is generally discouraged as it loses the event typing and
/// some DOM events may have multiple platform-dependent event names under the
/// covers. By using the standard Stream getters you will get the platform
/// specific event name automatically.
class Events {
final html.EventTarget _ptr;
Events(this._ptr);
Stream<html.Event> operator [](String type) =>
_EventStream(_ptr, type, false);
}
class ElementEvents extends Events {
static final _webkitEvents = {
'animationend': 'webkitAnimationEnd',
'animationiteration': 'webkitAnimationIteration',
'animationstart': 'webkitAnimationStart',
'fullscreenchange': 'webkitfullscreenchange',
'fullscreenerror': 'webkitfullscreenerror',
'keyadded': 'webkitkeyadded',
'keyerror': 'webkitkeyerror',
'keymessage': 'webkitkeymessage',
'needkey': 'webkitneedkey',
'pointerlockchange': 'webkitpointerlockchange',
'pointerlockerror': 'webkitpointerlockerror',
'resourcetimingbufferfull': 'webkitresourcetimingbufferfull',
'transitionend': 'webkitTransitionEnd',
'speechchange': 'webkitSpeechChange'
};
ElementEvents(html.Element super.ptr);
@override
Stream<html.Event> operator [](String type) {
if (_webkitEvents.keys.contains(type.toLowerCase())) {
if (Device.isWebKit) {
return _ElementEventStreamImpl(
_ptr, _webkitEvents[type.toLowerCase()]!, false);
}
}
return _ElementEventStreamImpl(_ptr, type, false);
}
}
/// Helper class to implement custom events which wrap DOM events.
// TODO(b/261997228): Add support for CustomEvents now that WrappedEvent is not
// implementing the JS interop html.Event type.
class WrappedEvent {
final html.Event wrapped;
/// The CSS selector involved with event delegation.
String? _selector;
WrappedEvent(this.wrapped);
bool get bubbles => wrapped.bubbles;
bool get cancelable => wrapped.cancelable;
bool get composed => wrapped.composed;
html.EventTarget? get currentTarget => wrapped.currentTarget;
bool get defaultPrevented => wrapped.defaultPrevented;
int get eventPhase => wrapped.eventPhase;
bool get isTrusted => wrapped.isTrusted;
html.EventTarget? get target => wrapped.target;
double get timeStamp => wrapped.timeStamp.toDouble();
String get type => wrapped.type;
void preventDefault() {
wrapped.preventDefault();
}
void stopImmediatePropagation() {
wrapped.stopImmediatePropagation();
}
void stopPropagation() {
wrapped.stopPropagation();
}
List<html.EventTarget> composedPath() =>
wrapped.composedPath().toDart.cast<html.EventTarget>();
html.Element get matchingTarget {
if (_selector == null) {
throw UnsupportedError('Cannot call matchingTarget if this Event did'
' not arise as a result of event delegation.');
}
final currentTarget = this.currentTarget as html.Element?;
var target = this.target as html.Element?;
do {
if (target!.matches(_selector!)) return target;
target = target.parentElement;
} while (target != null && target != currentTarget!.parentElement);
throw StateError('No selector matched for populating matchedTarget.');
}
}
void Function(T)? _wrapZone<T>(void Function(T)? callback) {
// For performance reasons avoid wrapping if we are in the root zone.
if (Zone.current == Zone.root) return callback;
if (callback == null) return null;
return Zone.current.bindUnaryCallbackGuarded(callback);
}
void Function(T1, T2)? wrapBinaryZone<T1, T2>(void Function(T1, T2)? callback) {
// For performance reasons avoid wrapping if we are in the root zone.
if (Zone.current == Zone.root) return callback;
if (callback == null) return null;
return Zone.current.bindBinaryCallbackGuarded(callback);
}
/// A stream of custom events, which enables the user to "fire" (add) their own
/// custom events to a stream.
abstract class CustomStream<T extends html.Event> implements Stream<T> {
/// Add the following custom event to the stream for dispatching to interested
/// listeners.
void add(T event);
}
class CustomEventStreamImpl<T extends html.Event> extends Stream<T>
implements CustomStream<T> {
StreamController<T> streamController;
/// The type of event this stream is providing (e.g. "keydown").
String type;
CustomEventStreamImpl(this.type)
: streamController = StreamController.broadcast(sync: true);
// Delegate all regular Stream behavior to our wrapped Stream.
@override
StreamSubscription<T> listen(void Function(T)? onData,
{Function? onError, void Function()? onDone, bool? cancelOnError}) =>
streamController.stream.listen(onData,
onError: onError, onDone: onDone, cancelOnError: cancelOnError);
@override
Stream<T> asBroadcastStream(
{void Function(StreamSubscription<T>)? onListen,
void Function(StreamSubscription<T>)? onCancel}) =>
streamController.stream;
@override
bool get isBroadcast => true;
@override
void add(T event) {
if (event.type == type) streamController.add(event);
}
}
/// A factory to expose DOM events as streams, where the DOM event name has to
/// be determined on the fly (for example, mouse wheel events).
class CustomEventStreamProvider<T extends html.Event>
implements EventStreamProvider<T> {
final String Function(html.EventTarget) _eventTypeGetter;
const CustomEventStreamProvider(this._eventTypeGetter);
@override
Stream<T> forTarget(html.EventTarget? e, {bool useCapture = false}) =>
_EventStream<T>(e, _eventTypeGetter(e!), useCapture);
@override
ElementStream<T> forElement(html.Element e, {bool useCapture = false}) =>
_ElementEventStreamImpl<T>(e, _eventTypeGetter(e), useCapture);
@override
String getEventType(html.EventTarget target) => _eventTypeGetter(target);
@override
String get _eventType =>
throw UnsupportedError('Access type through getEventType method.');
}