| // 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. |
| |
| /// Helper layer library that includes additional extension methods on existing |
| /// `package:web` types. |
| /// |
| /// The extensions here are added by hand over time, depending on needs and use |
| /// cases. They currently consist of: |
| /// |
| /// * renames: methods that provide the same functionality, but use a more |
| /// idiomatic Dart name. Typically these renames match the names used in |
| /// `dart:html` in the past. |
| /// |
| //// * operators: for example access local storage items using `operator[]`. |
| /// |
| /// * helper logic: commonly these are methods that were very useful and |
| /// popular from `dart:html` (like the context2D getter in |
| /// `HTMLCanvasElement`). |
| /// |
| /// * conversions: for example to wrap a `TouchList` as a `List<Touch>` |
| library; |
| |
| import 'dart:async'; |
| import 'dart:js_interop'; |
| import 'dart:js_interop_unsafe'; |
| import 'dart:math' show Point, Rectangle; |
| |
| import '../dom.dart' as dom; |
| import '../dom.dart'; |
| import 'attributes.dart'; |
| import 'css_class_set.dart'; |
| import 'css_rect.dart'; |
| import 'enums.dart'; |
| import 'equivalence.dart'; |
| import 'events/events.dart'; |
| import 'lists.dart'; |
| import 'maps.dart'; |
| import 'string_console.dart'; |
| |
| export 'cross_origin.dart' |
| show CrossOriginContentWindowExtension, CrossOriginWindowExtension; |
| |
| extension BlobExtension on Blob { |
| @Equivalence(type: 'Blob', member: '') |
| Blob createBlob(List<Object?> blobParts, [String? type, String? endings]) { |
| final jsParts = blobParts.jsify() as JSArray<JSAny>; |
| if (type != null && endings != null) { |
| return Blob(jsParts, BlobPropertyBag(type: type, endings: endings)); |
| } else if (type != null) { |
| return Blob(jsParts, BlobPropertyBag(type: type)); |
| } else if (endings != null) { |
| return Blob(jsParts, BlobPropertyBag(endings: endings)); |
| } else { |
| return Blob(jsParts); |
| } |
| } |
| } |
| |
| extension CanvasRenderingContext2DExtension on CanvasRenderingContext2D { |
| @Equivalence(type: 'CanvasRenderingContext2D', member: 'drawImageScaled') |
| @JS('drawImage') |
| external void drawImageScaled( |
| CanvasImageSource image, |
| double dx, |
| double dy, |
| double dw, |
| double dh, |
| ); |
| } |
| |
| extension CSSStyleDeclarationExtension on CSSStyleDeclaration { |
| @Equivalence( |
| type: 'CssStyleDeclaration', |
| member: 'setProperty', |
| details: |
| "'dart:html' used to check for support and add vendor prefixes for " |
| 'properties, but modern browsers no longer need this.', |
| ) |
| void setPropertyNullable(String property, String? value, [String? priority]) { |
| value ??= ''; |
| priority ??= ''; |
| setProperty(property, value, priority); |
| } |
| } |
| |
| extension DataTransferExtension on DataTransfer { |
| @Equivalence(type: 'DataTransfer', member: 'files') |
| List<File>? get filesAsList => has('files') ? files.asList() : null; |
| } |
| |
| extension DocumentExtension on Document { |
| @Equivalence( |
| type: 'Document', |
| member: 'querySelectorAll', |
| details: |
| "'dart:html' returned a `_FrozenElementList`, which provided " |
| 'additional helpers like stream listeners.', |
| ) |
| JSImmutableListWrapper<NodeList, T> querySelectorAllAsList<T extends Element>( |
| String selectors, |
| ) => JSImmutableListWrapper<NodeList, T>(querySelectorAll(selectors)); |
| } |
| |
| extension DOMRectExtension on DOMRect { |
| Rectangle<double> asRectangle() => Rectangle(left, top, width, height); |
| } |
| |
| extension DOMRectListExtension on DOMRectList { |
| List<Rectangle> asList() => JSImmutableListWrapper<DOMRectList, DOMRect>( |
| this, |
| ).map((rect) => rect.asRectangle()).toList(); |
| } |
| |
| extension DOMStringMapExtension on DOMStringMap { |
| bool containsKey(String name) => (this as JSObject)[name] != null; |
| } |
| |
| /// Contains subtype members that were moved up in `dart:html` to `Element`. |
| extension ElementExtension on Element { |
| @Equivalence(type: 'Element', member: 'appendText') |
| void appendText(String text) => append(Text(text)); |
| |
| @Equivalence(type: 'Element', member: 'attributes') |
| set attributes(Map<String, String> value) { |
| final attrs = attributesAsMap; |
| attrs.clear(); |
| value.forEach((key, val) { |
| attrs[key] = val; |
| }); |
| } |
| |
| @Equivalence(type: 'Element', member: 'attributes') |
| Map<String, String> get attributesAsMap => ElementAttributeMap(this); |
| |
| @Equivalence(type: 'Element', member: 'blur') |
| external void blur(); |
| |
| @Equivalence(type: 'Element', member: 'borderEdge') |
| CssRect get borderEdge => BorderCssRect(this); |
| |
| /// Returns [children] as a modifiable [List]. |
| @Equivalence(type: 'Element', member: 'children') |
| List<Element> get childrenAsList => HTMLCollectionListWrapper(this, children); |
| |
| @Equivalence(type: 'Element', member: 'classes') |
| CssClassSet get classes => ElementCssClassSet(this); |
| |
| @Equivalence(type: 'Element', member: 'click') |
| external void click(); |
| |
| @Equivalence(type: 'Element', member: 'dataset') |
| Map<String, String> get dataset => DataAttributeMap(attributesAsMap); |
| |
| @Equivalence(type: 'Element', member: 'dir') |
| external String? get dir; |
| @Equivalence(type: 'Element', member: 'dir') |
| external set dir(String? s); |
| |
| @Equivalence(type: 'Element', member: 'documentOffset') |
| Point get documentOffset => offsetTo(document.documentElement!); |
| |
| @Equivalence(type: 'Element', member: 'focus') |
| external void focus(); |
| |
| @Equivalence(type: 'Element', member: 'getComputedStyle') |
| CSSStyleDeclaration getComputedStyle([String? pseudoElement]) { |
| pseudoElement ??= ''; |
| return window.getComputedStyle(this, pseudoElement); |
| } |
| |
| @Equivalence(type: 'Element', member: 'innerText') |
| external String get innerText; |
| @Equivalence(type: 'Element', member: 'innerText') |
| external set innerText(String value); |
| |
| @Equivalence(type: 'Element', member: 'marginEdge') |
| CssRect get marginEdge => MarginCssRect(this); |
| |
| @Equivalence(type: 'Element', member: 'matchesWithAncestors') |
| bool matchesWithAncestors(String selectors) { |
| var elem = this as Element?; |
| while (elem != null) { |
| if (elem.matches(selectors)) return true; |
| elem = elem.parent; |
| } |
| return false; |
| } |
| |
| @Equivalence(type: 'Element', member: 'namespaceUri') |
| String? get namespaceUri => namespaceURI; |
| |
| @Equivalence(type: 'Element', member: 'offset') |
| Rectangle get offset => |
| Rectangle(offsetLeft, offsetTop, offsetWidth, offsetHeight); |
| |
| @Equivalence(type: 'Element', member: 'offsetHeight') |
| external int get offsetHeight; |
| |
| @Equivalence(type: 'Element', member: 'offsetLeft') |
| external int get offsetLeft; |
| |
| @Equivalence(type: 'Element', member: 'offsetParent') |
| external Element? get offsetParent; |
| |
| @Equivalence(type: 'Element', member: 'offsetTo') |
| Point offsetTo(Element parent) => |
| ElementExtension._offsetToHelper(this, parent); |
| |
| static Point _offsetToHelper(Element? current, Element parent) { |
| // We're hopping from _offsetParent_ to offsetParent (not just parent), so |
| // offsetParent, "tops out" at BODY. But people could conceivably pass in |
| // the document.documentElement and I want it to return an absolute offset, |
| // so we have the special case checking for HTML. |
| final sameAsParent = current.strictEquals(parent).toDart; |
| final foundAsParent = sameAsParent || parent.tagName == 'HTML'; |
| if (current == null || sameAsParent) { |
| if (foundAsParent) return const Point(0, 0); |
| throw ArgumentError( |
| 'Specified element is not a transitive offset parent of this element.', |
| ); |
| } |
| final parentOffset = current.offsetParent; |
| final p = ElementExtension._offsetToHelper(parentOffset, parent); |
| return Point(p.x + current.offsetLeft, p.y + current.offsetTop); |
| } |
| |
| @Equivalence(type: 'Element', member: 'offsetTop') |
| external int get offsetTop; |
| |
| @Equivalence(type: 'Element', member: 'offsetWidth') |
| external int get offsetWidth; |
| |
| @Equivalence( |
| type: 'Element', |
| member: 'querySelectorAll', |
| details: |
| "'dart:html' returned a `_FrozenElementList`, which provided " |
| 'additional helpers like stream listeners.', |
| ) |
| JSImmutableListWrapper<NodeList, T> querySelectorAllAsList<T extends Element>( |
| String selectors, |
| ) => JSImmutableListWrapper<NodeList, T>(querySelectorAll(selectors)); |
| |
| @Equivalence(type: 'Element', member: 'scrollIntoViewIfNeeded') |
| @JS('scrollIntoViewIfNeeded') |
| external void scrollIntoViewIfNeeded([bool? centerIfNeeded]); |
| |
| @Equivalence(type: 'Element', member: 'scrollIntoView') |
| void scrollIntoViewWithAlignment([ScrollAlignment? alignment]) { |
| final hasScrollIntoViewIfNeeded = has('scrollIntoViewIfNeeded'); |
| if (alignment == ScrollAlignment.TOP) { |
| scrollIntoView(true.toJS); |
| } else if (alignment == ScrollAlignment.BOTTOM) { |
| scrollIntoView(false.toJS); |
| } else if (hasScrollIntoViewIfNeeded) { |
| if (alignment == ScrollAlignment.CENTER) { |
| scrollIntoViewIfNeeded(true); |
| } else { |
| scrollIntoViewIfNeeded(); |
| } |
| } else { |
| scrollIntoView(); |
| } |
| } |
| |
| @Equivalence(type: 'Element', member: 'style') |
| external CSSStyleDeclaration get style; |
| |
| @Equivalence(type: 'Element', member: 'tabIndex') |
| external int? get tabIndex; |
| @Equivalence(type: 'Element', member: 'tabIndex') |
| external set tabIndex(int? v); |
| } |
| |
| extension EventExtension on Event { |
| @Equivalence(type: 'Event', member: 'path') |
| List<EventTarget> get path => |
| has('composedPath') ? composedPath().toDart : []; |
| } |
| |
| extension EventTargetExtension on EventTarget { |
| @Equivalence(type: 'EventTarget', member: 'addEventListener') |
| void addEventListenerWithFunction<T extends Event>( |
| String type, |
| void Function(T) onData, [ |
| bool useCapture = false, |
| ]) { |
| addEventListener(type, onData.toJS, useCapture.toJS); |
| } |
| } |
| |
| extension FileListExtension on FileList { |
| List<File> asList() => JSImmutableListWrapper<FileList, File>(this); |
| |
| @Equivalence(type: 'FileList', member: 'elementAt') |
| File elementAt(int i) => item(i)!; |
| |
| @Equivalence(type: 'FileList', member: 'first') |
| File get first => item(0)!; |
| |
| @Equivalence(type: 'FileList', member: 'isEmpty') |
| bool get isEmpty => length == 0; |
| } |
| |
| extension HTMLCanvasElementExtension on HTMLCanvasElement { |
| @Equivalence(type: 'CanvasElement', member: 'context2D') |
| CanvasRenderingContext2D get context2D => |
| getContext('2d') as CanvasRenderingContext2D; |
| |
| @Equivalence(type: 'CanvasElement', member: 'getContext3d') |
| RenderingContext? getContext3d({ |
| bool alpha = true, |
| bool depth = true, |
| bool stencil = false, |
| bool antialias = true, |
| bool premultipliedAlpha = true, |
| bool preserveDrawingBuffer = false, |
| }) { |
| final options = JSObject() |
| ..['alpha'] = alpha.toJS |
| ..['depth'] = depth.toJS |
| ..['stencil'] = stencil.toJS |
| ..['antialias'] = antialias.toJS |
| ..['premultipliedAlpha'] = premultipliedAlpha.toJS |
| ..['preserveDrawingBuffer'] = preserveDrawingBuffer.toJS; |
| return getContext('webgl', options) ?? |
| getContext('experimental-webgl', options); |
| } |
| |
| @Equivalence(type: 'CanvasElement', member: 'toBlob') |
| Future<Blob> toBlobFuture([String? type, num? quality]) { |
| final completer = Completer<Blob>(); |
| void getBlob(Blob value) { |
| completer.complete(value); |
| } |
| |
| final blobCallback = getBlob.toJS; |
| if (type == null) { |
| toBlob(blobCallback); |
| } else if (quality == null) { |
| toBlob(blobCallback, type); |
| } else { |
| toBlob(blobCallback, type, quality.toJS); |
| } |
| return completer.future; |
| } |
| |
| @Equivalence(type: 'CanvasElement', member: 'toDataUrl') |
| String toDataUrl([String type = 'image/png', num? quality]) => |
| (quality == null) ? toDataURL(type) : toDataURL(type, quality.toJS); |
| } |
| |
| extension HTMLCollectionExtension on HTMLCollection { |
| List<Element> asList() => |
| JSImmutableListWrapper<HTMLCollection, Element>(this); |
| |
| @Equivalence(type: 'HtmlCollection', member: 'first') |
| Element get first => item(0)!; |
| |
| @Equivalence(type: 'HtmlCollection', member: 'isEmpty') |
| bool get isEmpty => length == 0; |
| |
| @Equivalence(type: 'HtmlCollection', member: 'isNotEmpty') |
| bool get isNotEmpty => length > 0; |
| |
| @Equivalence(type: 'HtmlCollection', member: 'last') |
| Element get last => item(length - 1)!; |
| |
| @Equivalence(type: 'HtmlCollection', member: 'single') |
| Element get single { |
| if (length != 1) { |
| throw ArgumentError('HTMLCollection must have exactly one element.'); |
| } |
| return item(0)!; |
| } |
| |
| @Equivalence(type: 'HtmlCollection', member: '[]') |
| Element operator [](int i) => item(i)!; |
| } |
| |
| extension HTMLInputElementExtension on HTMLInputElement { |
| @Equivalence(type: 'InputElement', member: 'files') |
| List<File>? get filesAsList => files?.asList(); |
| } |
| |
| extension MouseEventExtension on MouseEvent { |
| @Equivalence(type: 'MouseEvent', member: 'client') |
| Point get client => Point(clientX, clientY); |
| |
| // This really belongs on the `DragEvent` subtype, but `dart:html` moved it |
| // up. |
| @Equivalence(type: 'MouseEvent', member: 'dataTransfer') |
| external DataTransfer get dataTransfer; |
| |
| @Equivalence(type: 'MouseEvent', member: 'page') |
| Point get page => Point(pageX, pageY); |
| |
| @Equivalence(type: 'MouseEvent', member: 'screen') |
| Point get screen => Point(screenX, screenY); |
| } |
| |
| extension NodeExtension on Node { |
| @Equivalence(type: 'Node', member: 'append') |
| Node append(Node other) => appendChild(other); |
| |
| /// Returns [childNodes] as a modifiable [List]. |
| @Equivalence(type: 'Node', member: 'childNodes') |
| List<Node> get childNodesAsList => NodeListListWrapper(this, childNodes); |
| |
| @Equivalence(type: 'Node', member: 'clone') |
| Node clone(bool? deep) => cloneNode(deep ?? false); |
| |
| @Equivalence(type: 'Node', member: 'insertAllBefore') |
| void insertAllBefore(Iterable<Node> newNodes, Node child) { |
| // `toList` to avoid potential infinite recursion if `newNodes` is a wrapper |
| // around this `Node`. |
| for (var node in newNodes.toList()) { |
| insertBefore(node, child); |
| } |
| } |
| |
| @Equivalence(type: 'Node', member: 'nextNode') |
| Node? get nextNode => nextSibling; |
| |
| @Equivalence(type: 'Node', member: 'nodes') |
| List<Node> get nodes => childNodesAsList; |
| |
| @Equivalence(type: 'Node', member: 'nodes') |
| set nodes(Iterable<Node> value) { |
| final copy = value.toList(); |
| text = ''; |
| for (final node in copy) { |
| appendChild(node); |
| } |
| } |
| |
| @Equivalence(type: 'Node', member: 'parent') |
| Element? get parent => parentElement; |
| |
| @Equivalence(type: 'Node', member: 'remove') |
| void remove() { |
| final parent = parentNode; |
| if (parent != null) { |
| parent.removeChild(this); |
| } |
| } |
| |
| @Equivalence(type: 'Node', member: 'text') |
| String? get text => textContent; |
| |
| @Equivalence(type: 'Node', member: 'text') |
| set text(String? s) => textContent = s; |
| } |
| |
| extension NodeListExtension on NodeList { |
| /// Returns node list as an immutable [List]. |
| List<T> asList<T extends Node>() => JSImmutableListWrapper<NodeList, T>(this); |
| |
| @Equivalence(type: 'NodeList', member: 'elementAt') |
| Node elementAt(int i) => item(i)!; |
| |
| @Equivalence(type: 'NodeList', member: 'isEmpty') |
| bool get isEmpty => length == 0; |
| } |
| |
| extension StorageExtension on Storage { |
| @Equivalence(type: 'Storage', member: '[]') |
| String? operator [](String key) => getItem(key); |
| |
| @Equivalence(type: 'Storage', member: '[]=') |
| void operator []=(String key, String value) => setItem(key, value); |
| } |
| |
| extension TouchEventExtension on TouchEvent { |
| // Safari still doesn't support `TouchEvent`s: |
| // https://developer.mozilla.org/en-US/docs/Web/API/TouchEvent |
| @Equivalence(type: 'TouchEvent', member: 'supported') |
| static bool get supported { |
| try { |
| TouchEvent('touches'); |
| return true; |
| } catch (e) { |
| return false; |
| } |
| } |
| } |
| |
| extension TouchExtension on Touch { |
| @Equivalence(type: 'Touch', member: 'client') |
| Point get client => Point(clientX, clientY); |
| |
| @Equivalence(type: 'Touch', member: 'page') |
| Point get page => Point(pageX, pageY); |
| } |
| |
| extension TouchListExtension on TouchList { |
| List<Touch> asList() => JSImmutableListWrapper<TouchList, Touch>(this); |
| |
| @Equivalence(type: 'TouchList', member: 'elementAt') |
| Touch elementAt(int i) => item(i)!; |
| |
| @Equivalence(type: 'TouchList', member: 'first') |
| Touch get first => item(0)!; |
| |
| @Equivalence(type: 'TouchList', member: 'isEmpty') |
| bool get isEmpty => length == 0; |
| } |
| |
| extension UriExtension on Uri { |
| /// Converts this to a JavaScript [URL] object. |
| /// |
| /// Throws an [ArgumentError] if this isn't an absolute URL, since [URL] can |
| /// only represent absolute URLs. |
| URL get toJS { |
| try { |
| return URL(toString()); |
| } catch (_) { |
| throw ArgumentError.value(this, 'this', '"$this" isn\'t a valid JS URL.'); |
| } |
| } |
| } |
| |
| extension URLExtension on URL { |
| /// Converts this to a Dart [Uri] object. |
| Uri get toDart => Uri.parse(toString()); |
| } |
| |
| extension WindowExtension on Window { |
| @Equivalence(type: 'Window', member: 'animationFrame') |
| Future<num> get animationFrame { |
| final completer = Completer<num>.sync(); |
| void getTimestamp(num timestamp) { |
| completer.complete(timestamp); |
| } |
| |
| requestAnimationFrame(getTimestamp.toJS); |
| return completer.future; |
| } |
| |
| @Equivalence(type: 'Window', member: 'console') |
| StringConsole get console => StringConsole(dom.console); |
| |
| @Equivalence(type: 'Window', member: 'localStorage') |
| StorageMap get localStorageAsMap => StorageMap(localStorage); |
| |
| @Equivalence(type: 'Window', member: 'sessionStorage') |
| StorageMap get sessionStorageAsMap => StorageMap(sessionStorage); |
| } |
| |
| extension XMLHttpRequestExtension on XMLHttpRequest { |
| @Equivalence(type: 'HttpRequest', member: 'request') |
| static Future<XMLHttpRequest> request( |
| String url, { |
| String? method, |
| bool? withCredentials, |
| String? responseType, |
| String? mimeType, |
| Map<String, String>? requestHeaders, |
| Object? sendData, |
| void Function(ProgressEvent e)? onProgress, |
| }) { |
| final completer = Completer<XMLHttpRequest>(); |
| |
| final xhr = XMLHttpRequest(); |
| method ??= 'GET'; |
| xhr.open(method, url, true); |
| |
| if (withCredentials != null) { |
| xhr.withCredentials = withCredentials; |
| } |
| |
| if (responseType != null) { |
| xhr.responseType = responseType; |
| } |
| |
| if (mimeType != null) { |
| xhr.overrideMimeType(mimeType); |
| } |
| |
| if (requestHeaders != null) { |
| requestHeaders.forEach( |
| // Can't tear-off a JS interop function. |
| // ignore: unnecessary_lambdas |
| (String name, String value) => xhr.setRequestHeader(name, value), |
| ); |
| } |
| |
| if (onProgress != null) { |
| xhr.onProgress.listen(onProgress); |
| } |
| |
| xhr.onLoad.listen((e) { |
| final status = xhr.status; |
| final accepted = status >= 200 && status < 300; |
| final fileUri = status == 0; |
| final notModified = status == 304; |
| final unknownRedirect = status > 307 && status < 400; |
| |
| if (accepted || fileUri || notModified || unknownRedirect) { |
| completer.complete(xhr); |
| } else { |
| completer.completeError(e); |
| } |
| }); |
| |
| xhr.onError.listen(completer.completeError); |
| |
| if (sendData != null) { |
| // `dart:html` accepted any value before. These values may be typed data, |
| // so we may need to do a `jsify`. However, it's faster to check if it's a |
| // JS value before we attempt a `jsify`, so do that. |
| xhr.send(sendData.isA<JSAny?>() ? sendData as JSAny? : sendData.jsify()); |
| } else { |
| xhr.send(); |
| } |
| |
| return completer.future; |
| } |
| |
| @Equivalence(type: 'HttpRequest', member: 'responseHeaders') |
| Map<String, String> get responseHeaders { |
| final headers = <String, String>{}; |
| final headersString = getAllResponseHeaders(); |
| final headersList = headersString.split('\r\n'); |
| for (final header in headersList) { |
| if (header.isEmpty) { |
| continue; |
| } |
| final splitIdx = header.indexOf(': '); |
| if (splitIdx == -1) { |
| continue; |
| } |
| final key = header.substring(0, splitIdx).toLowerCase(); |
| final value = header.substring(splitIdx + 2); |
| if (headers.containsKey(key)) { |
| headers[key] = '${headers[key]}, $value'; |
| } else { |
| headers[key] = value; |
| } |
| } |
| return headers; |
| } |
| } |
| |
| @Equivalence(type: null, member: 'querySelector') |
| Element? querySelector(String selectors) => document.querySelector(selectors); |