From 2b38440faa43048f5bc98bce0f0b2e5045d86620 Mon Sep 17 00:00:00 2001 From: Mouad Debbar Date: Wed, 14 Oct 2020 16:00:39 -0700 Subject: [PATCH 1/8] [web] Implement Link for web --- .../url_launcher_web/CHANGELOG.md | 4 + .../url_launcher_web/lib/link.dart | 282 ++++++++++++++++++ .../lib/url_launcher_web.dart | 7 + .../url_launcher_web/pubspec.yaml | 4 +- 4 files changed, 295 insertions(+), 2 deletions(-) create mode 100644 packages/url_launcher/url_launcher_web/lib/link.dart diff --git a/packages/url_launcher/url_launcher_web/CHANGELOG.md b/packages/url_launcher/url_launcher_web/CHANGELOG.md index 456d458834bf..e7abd1301c88 100644 --- a/packages/url_launcher/url_launcher_web/CHANGELOG.md +++ b/packages/url_launcher/url_launcher_web/CHANGELOG.md @@ -1,3 +1,7 @@ +# 0.1.5 + +- Added the web implementation of the Link widget. + # 0.1.4+2 - Move `lib/third_party` to `lib/src/third_party`. diff --git a/packages/url_launcher/url_launcher_web/lib/link.dart b/packages/url_launcher/url_launcher_web/lib/link.dart new file mode 100644 index 000000000000..9874d00e8741 --- /dev/null +++ b/packages/url_launcher/url_launcher_web/lib/link.dart @@ -0,0 +1,282 @@ +// Copyright 2017 The Chromium Authors. 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:html' as html; +import 'dart:js_util'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; + +import 'package:url_launcher_platform_interface/link.dart'; + +typedef _ClickListener = void Function(html.MouseEvent); + +/// The unique identifier for the view type to be used for link platform views. +const String viewType = '__url_launcher::link'; + +/// The name of the property used to set the viewId on the DOM element. +const String _viewIdProperty = '__url_launcher::link::viewId'; + +/// Signature for a function that takes a unique [id] and creates an HTML element. +typedef PlatformViewFactory = html.Element Function(int viewId); + +/// Factory that returns the link DOM element for each unique view id. +PlatformViewFactory get linkViewFactory => _LinkViewController._viewFactory; + +/// The delegate for building the [Link] widget on the web. +/// +/// It uses a platform view to render an anchor element in the DOM. +class WebLinkDelegate extends StatefulWidget { + /// Creates a delegate for the given [link]. + const WebLinkDelegate(this.link); + + /// Information about the link built by the app. + final LinkInfo link; + + @override + _WebLinkDelegateState createState() => _WebLinkDelegateState(); +} + +class _WebLinkDelegateState extends State { + _LinkViewController _controller; + + @override + void didUpdateWidget(WebLinkDelegate oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.link.uri != oldWidget.link.uri) { + _controller?._setUri(widget.link.uri); + } + if (widget.link.target != oldWidget.link.target) { + _controller?._setTarget(widget.link.target); + } + } + + Future _followLink() { + final Completer completer = Completer(); + _LinkViewController._registerHitTest( + _controller, + onClick: (html.MouseEvent event) { + completer.complete(_onDomClick(event)); + }, + ); + return completer.future; + } + + Future _onDomClick(html.MouseEvent event) { + if (!widget.link.uri.hasScheme) { + // A uri that doesn't have a scheme is an internal route name. In this + // case, we push it via Flutter's navigation system instead of letting the + // browser handle it. + event.preventDefault(); + final String routeName = widget.link.uri.toString(); + // TODO(mdebbar): how do we know if `isUsingRouter` should be true or false? + return pushRouteNameToFramework(routeName, isUsingRouter: false); + } + + // External links will be handled by the browser, so we don't have to do + // anything. + return Future.value(null); + } + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + widget.link.builder( + context, + widget.link.isDisabled ? null : _followLink, + ), + Positioned.fill( + child: PlatformViewLink( + viewType: viewType, + onCreatePlatformView: _createController, + surfaceFactory: + (BuildContext context, PlatformViewController controller) { + return PlatformViewSurface( + controller: controller, + gestureRecognizers: + Set>(), + hitTestBehavior: PlatformViewHitTestBehavior.transparent, + ); + }, + ), + ), + ], + ); + } + + _LinkViewController _createController(PlatformViewCreationParams params) { + _controller = _LinkViewController(params.id); + _controller._initialize().then((_) { + params.onPlatformViewCreated(params.id); + }); + return _controller + .._setUri(widget.link.uri) + .._setTarget(widget.link.target); + } +} + +/// Controls link views. +class _LinkViewController extends PlatformViewController { + _LinkViewController(this.viewId) { + if (_instances.isEmpty) { + // This is the first controller being created, attach the global click + // listener. + _clickSubscribtion = html.window.onClick.listen(_onGlobalClick); + } + _instances[viewId] = this; + } + + static Map _instances = + {}; + + static html.Element _viewFactory(int viewId) { + return _instances[viewId]?._element; + } + + static int _hitTestedViewId; + static _ClickListener _hitTestedClickCallback; + + static StreamSubscription _clickSubscribtion; + + static void _onGlobalClick(html.MouseEvent event) { + final int viewId = _getViewIdFromTarget(event); + _instances[viewId]?._onDomClick(event); + // After the DOM click event has been received, clean up the hit test state + // so we can start fresh on the next click. + _unregisterHitTest(); + } + + /// Call this method to indicated that a hit test has been registered for the + /// given [controller]. + /// + /// The [onClick] callback is invoked when the anchor element receives a + /// `click` from the browser. + static void _registerHitTest( + _LinkViewController controller, { + @required _ClickListener onClick, + }) { + _hitTestedViewId = controller.viewId; + _hitTestedClickCallback = onClick; + } + + static void _unregisterHitTest() { + _hitTestedViewId = null; + _hitTestedClickCallback = null; + } + + @override + final int viewId; + + html.Element _element; + bool get _isInitialized => _element != null; + + Future _initialize() async { + _element = html.Element.tag('a'); + setProperty(_element, _viewIdProperty, viewId); + _element.style + ..opacity = '0' + ..display = 'block' + ..cursor = 'unset'; + + final Map args = { + 'id': viewId, + 'viewType': viewType, + }; + await SystemChannels.platform_views.invokeMethod('create', args); + } + + void _onDomClick(html.MouseEvent event) { + final bool isHitTested = _hitTestedViewId == viewId; + if (isHitTested) { + _hitTestedClickCallback(event); + } else { + // There was no hit test registered for this click. This means the click + // landed on the anchor element but not on the underlying widget. In this + // case, we prevent the browser from following the click. + event.preventDefault(); + } + } + + void _setUri(Uri uri) { + assert(_isInitialized); + if (uri == null) { + _element.removeAttribute('href'); + } else { + _element.setAttribute('href', uri.toString()); + } + } + + void _setTarget(LinkTarget target) { + assert(_isInitialized); + _element.setAttribute('target', _getHtmlTarget(target)); + } + + String _getHtmlTarget(LinkTarget target) { + switch (target) { + case LinkTarget.defaultTarget: + case LinkTarget.self: + return '_self'; + case LinkTarget.blank: + return '_blank'; + default: + throw Exception('Unknown LinkTarget value $target.'); + } + } + + @override + Future clearFocus() async { + // Currently this does nothing on Flutter Web. + // TODO(het): Implement this. See https://github.com/flutter/flutter/issues/39496 + } + + @override + Future dispatchPointerEvent(PointerEvent event) async { + // We do not dispatch pointer events to HTML views because they may contain + // cross-origin iframes, which only accept user-generated events. + } + + @override + Future dispose() async { + if (_isInitialized) { + assert(_instances[viewId] == this); + _instances.remove(viewId); + if (_instances.isEmpty) { + await _clickSubscribtion.cancel(); + } + // Asynchronously dispose this view. + await SystemChannels.platform_views.invokeMethod('dispose', viewId); + } + } +} + +int _getViewIdFromTarget(html.Event event) { + final html.Element linkElement = _getLinkElementFromTarget(event); + if (linkElement != null) { + return getProperty(linkElement, _viewIdProperty); + } + return null; +} + +html.Element _getLinkElementFromTarget(html.Event event) { + final html.Element target = event.target; + if (_isLinkElement(target)) { + return target; + } + if (target.shadowRoot != null) { + final html.Element child = target.shadowRoot.lastChild; + if (_isLinkElement(child)) { + return child; + } + } + return null; +} + +bool _isLinkElement(html.Element element) { + return element.tagName == 'A' && hasProperty(element, _viewIdProperty); +} diff --git a/packages/url_launcher/url_launcher_web/lib/url_launcher_web.dart b/packages/url_launcher/url_launcher_web/lib/url_launcher_web.dart index 093e06a4d8ed..0c3c9d6afd67 100644 --- a/packages/url_launcher/url_launcher_web/lib/url_launcher_web.dart +++ b/packages/url_launcher/url_launcher_web/lib/url_launcher_web.dart @@ -4,10 +4,14 @@ import 'dart:async'; import 'dart:html' as html; +// ignore: undefined_shown_name +import 'dart:ui' as ui show platformViewRegistry; import 'package:flutter_web_plugins/flutter_web_plugins.dart'; import 'package:meta/meta.dart'; +import 'package:url_launcher_platform_interface/link.dart'; import 'package:url_launcher_platform_interface/url_launcher_platform_interface.dart'; +import 'package:url_launcher_web/link.dart'; import 'src/third_party/platform_detect/browser.dart'; @@ -43,6 +47,9 @@ class UrlLauncherPlugin extends UrlLauncherPlatform { /// Registers this class as the default instance of [UrlLauncherPlatform]. static void registerWith(Registrar registrar) { UrlLauncherPlatform.instance = UrlLauncherPlugin(); + linkDelegate = (LinkInfo linkInfo) => WebLinkDelegate(linkInfo); + // ignore: undefined_prefixed_name + ui.platformViewRegistry.registerViewFactory(viewType, linkViewFactory); } /// Opens the given [url] in the specified [webOnlyWindowName]. diff --git a/packages/url_launcher/url_launcher_web/pubspec.yaml b/packages/url_launcher/url_launcher_web/pubspec.yaml index 957b25757036..7ae84cdfb07e 100644 --- a/packages/url_launcher/url_launcher_web/pubspec.yaml +++ b/packages/url_launcher/url_launcher_web/pubspec.yaml @@ -4,7 +4,7 @@ homepage: https://github.com/flutter/plugins/tree/master/packages/url_launcher/u # 0.1.y+z is compatible with 1.0.0, if you land a breaking change bump # the version to 2.0.0. # See more details: https://github.com/flutter/flutter/wiki/Package-migration-to-1.0.0 -version: 0.1.4+2 +version: 0.1.5 flutter: plugin: @@ -14,7 +14,7 @@ flutter: fileName: url_launcher_web.dart dependencies: - url_launcher_platform_interface: ^1.0.8 + url_launcher_platform_interface: ^1.0.9 flutter: sdk: flutter flutter_web_plugins: From ea048e0ce9ccf4bf748905fb049f52d4af8554e0 Mon Sep 17 00:00:00 2001 From: Mouad Debbar Date: Thu, 15 Oct 2020 10:11:57 -0700 Subject: [PATCH 2/8] address comments --- .../url_launcher_web/lib/{ => src}/link.dart | 95 +++++++++++-------- .../lib/url_launcher_web.dart | 4 +- 2 files changed, 57 insertions(+), 42 deletions(-) rename packages/url_launcher/url_launcher_web/lib/{ => src}/link.dart (71%) diff --git a/packages/url_launcher/url_launcher_web/lib/link.dart b/packages/url_launcher/url_launcher_web/lib/src/link.dart similarity index 71% rename from packages/url_launcher/url_launcher_web/lib/link.dart rename to packages/url_launcher/url_launcher_web/lib/src/link.dart index 9874d00e8741..fefa7a8f216b 100644 --- a/packages/url_launcher/url_launcher_web/lib/link.dart +++ b/packages/url_launcher/url_launcher_web/lib/src/link.dart @@ -14,19 +14,20 @@ import 'package:flutter/services.dart'; import 'package:url_launcher_platform_interface/link.dart'; -typedef _ClickListener = void Function(html.MouseEvent); +/// Signature for a function that handles a mouse click event. +typedef ClickListener = void Function(html.MouseEvent); /// The unique identifier for the view type to be used for link platform views. -const String viewType = '__url_launcher::link'; +const String linkViewType = '__url_launcher::link'; /// The name of the property used to set the viewId on the DOM element. -const String _viewIdProperty = '__url_launcher::link::viewId'; +const String linkViewIdProperty = '__url_launcher::link::viewId'; /// Signature for a function that takes a unique [id] and creates an HTML element. -typedef PlatformViewFactory = html.Element Function(int viewId); +typedef HtmlViewFactory = html.Element Function(int viewId); /// Factory that returns the link DOM element for each unique view id. -PlatformViewFactory get linkViewFactory => _LinkViewController._viewFactory; +HtmlViewFactory get linkViewFactory => LinkViewController._viewFactory; /// The delegate for building the [Link] widget on the web. /// @@ -39,26 +40,30 @@ class WebLinkDelegate extends StatefulWidget { final LinkInfo link; @override - _WebLinkDelegateState createState() => _WebLinkDelegateState(); + WebLinkDelegateState createState() => WebLinkDelegateState(); } -class _WebLinkDelegateState extends State { - _LinkViewController _controller; +/// The link delegate used on the web platform. +/// +/// For external URIs, it lets the browser do its thing. For app route names, it +/// pushes the route name to the framework. +class WebLinkDelegateState extends State { + LinkViewController _controller; @override void didUpdateWidget(WebLinkDelegate oldWidget) { super.didUpdateWidget(oldWidget); if (widget.link.uri != oldWidget.link.uri) { - _controller?._setUri(widget.link.uri); + _controller?.setUri(widget.link.uri); } if (widget.link.target != oldWidget.link.target) { - _controller?._setTarget(widget.link.target); + _controller?.setTarget(widget.link.target); } } Future _followLink() { final Completer completer = Completer(); - _LinkViewController._registerHitTest( + LinkViewController.registerHitTest( _controller, onClick: (html.MouseEvent event) { completer.complete(_onDomClick(event)); @@ -93,7 +98,7 @@ class _WebLinkDelegateState extends State { ), Positioned.fill( child: PlatformViewLink( - viewType: viewType, + viewType: linkViewType, onCreatePlatformView: _createController, surfaceFactory: (BuildContext context, PlatformViewController controller) { @@ -110,20 +115,21 @@ class _WebLinkDelegateState extends State { ); } - _LinkViewController _createController(PlatformViewCreationParams params) { - _controller = _LinkViewController(params.id); + LinkViewController _createController(PlatformViewCreationParams params) { + _controller = LinkViewController(params.id); _controller._initialize().then((_) { params.onPlatformViewCreated(params.id); }); return _controller - .._setUri(widget.link.uri) - .._setTarget(widget.link.target); + ..setUri(widget.link.uri) + ..setTarget(widget.link.target); } } /// Controls link views. -class _LinkViewController extends PlatformViewController { - _LinkViewController(this.viewId) { +class LinkViewController extends PlatformViewController { + /// Creates a [LinkViewController] instance with the unique [viewId]. + LinkViewController(this.viewId) { if (_instances.isEmpty) { // This is the first controller being created, attach the global click // listener. @@ -132,40 +138,41 @@ class _LinkViewController extends PlatformViewController { _instances[viewId] = this; } - static Map _instances = - {}; + static Map _instances = + {}; static html.Element _viewFactory(int viewId) { return _instances[viewId]?._element; } static int _hitTestedViewId; - static _ClickListener _hitTestedClickCallback; + static ClickListener _hitTestedClickCallback; static StreamSubscription _clickSubscribtion; static void _onGlobalClick(html.MouseEvent event) { - final int viewId = _getViewIdFromTarget(event); + final int viewId = getViewIdFromTarget(event); _instances[viewId]?._onDomClick(event); // After the DOM click event has been received, clean up the hit test state // so we can start fresh on the next click. - _unregisterHitTest(); + unregisterHitTest(); } - /// Call this method to indicated that a hit test has been registered for the + /// Call this method to indicate that a hit test has been registered for the /// given [controller]. /// /// The [onClick] callback is invoked when the anchor element receives a /// `click` from the browser. - static void _registerHitTest( - _LinkViewController controller, { - @required _ClickListener onClick, + static void registerHitTest( + LinkViewController controller, { + @required ClickListener onClick, }) { _hitTestedViewId = controller.viewId; _hitTestedClickCallback = onClick; } - static void _unregisterHitTest() { + /// Removes all information about previously registered hit tests. + static void unregisterHitTest() { _hitTestedViewId = null; _hitTestedClickCallback = null; } @@ -178,7 +185,7 @@ class _LinkViewController extends PlatformViewController { Future _initialize() async { _element = html.Element.tag('a'); - setProperty(_element, _viewIdProperty, viewId); + setProperty(_element, linkViewIdProperty, viewId); _element.style ..opacity = '0' ..display = 'block' @@ -186,7 +193,7 @@ class _LinkViewController extends PlatformViewController { final Map args = { 'id': viewId, - 'viewType': viewType, + 'viewType': linkViewType, }; await SystemChannels.platform_views.invokeMethod('create', args); } @@ -203,7 +210,8 @@ class _LinkViewController extends PlatformViewController { } } - void _setUri(Uri uri) { + /// Set the [Uri] value for this link. + void setUri(Uri uri) { assert(_isInitialized); if (uri == null) { _element.removeAttribute('href'); @@ -212,7 +220,8 @@ class _LinkViewController extends PlatformViewController { } } - void _setTarget(LinkTarget target) { + /// Set the [LinkTarget] value for this link. + void setTarget(LinkTarget target) { assert(_isInitialized); _element.setAttribute('target', _getHtmlTarget(target)); } @@ -255,28 +264,34 @@ class _LinkViewController extends PlatformViewController { } } -int _getViewIdFromTarget(html.Event event) { - final html.Element linkElement = _getLinkElementFromTarget(event); +/// Finds the view id of the DOM element targeted by the [event]. +int getViewIdFromTarget(html.Event event) { + final html.Element linkElement = getLinkElementFromTarget(event); if (linkElement != null) { - return getProperty(linkElement, _viewIdProperty); + return getProperty(linkElement, linkViewIdProperty); } return null; } -html.Element _getLinkElementFromTarget(html.Event event) { +/// Finds the targeted DOM element by the [event]. +/// +/// It handles the case where the target element is inside a shadow DOM too. +html.Element getLinkElementFromTarget(html.Event event) { final html.Element target = event.target; - if (_isLinkElement(target)) { + if (isLinkElement(target)) { return target; } if (target.shadowRoot != null) { final html.Element child = target.shadowRoot.lastChild; - if (_isLinkElement(child)) { + if (isLinkElement(child)) { return child; } } return null; } -bool _isLinkElement(html.Element element) { - return element.tagName == 'A' && hasProperty(element, _viewIdProperty); +/// Checks if the given [element] is a link that was created by +/// [LinkViewController]. +bool isLinkElement(html.Element element) { + return element.tagName == 'A' && hasProperty(element, linkViewIdProperty); } diff --git a/packages/url_launcher/url_launcher_web/lib/url_launcher_web.dart b/packages/url_launcher/url_launcher_web/lib/url_launcher_web.dart index 0c3c9d6afd67..76e15330d21b 100644 --- a/packages/url_launcher/url_launcher_web/lib/url_launcher_web.dart +++ b/packages/url_launcher/url_launcher_web/lib/url_launcher_web.dart @@ -11,8 +11,8 @@ import 'package:flutter_web_plugins/flutter_web_plugins.dart'; import 'package:meta/meta.dart'; import 'package:url_launcher_platform_interface/link.dart'; import 'package:url_launcher_platform_interface/url_launcher_platform_interface.dart'; -import 'package:url_launcher_web/link.dart'; +import 'src/link.dart'; import 'src/third_party/platform_detect/browser.dart'; const _safariTargetTopSchemes = { @@ -49,7 +49,7 @@ class UrlLauncherPlugin extends UrlLauncherPlatform { UrlLauncherPlatform.instance = UrlLauncherPlugin(); linkDelegate = (LinkInfo linkInfo) => WebLinkDelegate(linkInfo); // ignore: undefined_prefixed_name - ui.platformViewRegistry.registerViewFactory(viewType, linkViewFactory); + ui.platformViewRegistry.registerViewFactory(linkViewType, linkViewFactory); } /// Opens the given [url] in the specified [webOnlyWindowName]. From d5ec9c5582a96d086dda342ec2313e92c9d21fbe Mon Sep 17 00:00:00 2001 From: Mouad Debbar Date: Thu, 15 Oct 2020 17:34:46 -0700 Subject: [PATCH 3/8] changes --- packages/url_launcher/url_launcher_web/lib/src/link.dart | 6 ++---- .../url_launcher/url_launcher_web/lib/url_launcher_web.dart | 4 +++- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/url_launcher/url_launcher_web/lib/src/link.dart b/packages/url_launcher/url_launcher_web/lib/src/link.dart index fefa7a8f216b..05fde311012c 100644 --- a/packages/url_launcher/url_launcher_web/lib/src/link.dart +++ b/packages/url_launcher/url_launcher_web/lib/src/link.dart @@ -79,8 +79,7 @@ class WebLinkDelegateState extends State { // browser handle it. event.preventDefault(); final String routeName = widget.link.uri.toString(); - // TODO(mdebbar): how do we know if `isUsingRouter` should be true or false? - return pushRouteNameToFramework(routeName, isUsingRouter: false); + return pushRouteNameToFramework(context, routeName); } // External links will be handled by the browser, so we don't have to do @@ -138,8 +137,7 @@ class LinkViewController extends PlatformViewController { _instances[viewId] = this; } - static Map _instances = - {}; + static Map _instances = {}; static html.Element _viewFactory(int viewId) { return _instances[viewId]?._element; diff --git a/packages/url_launcher/url_launcher_web/lib/url_launcher_web.dart b/packages/url_launcher/url_launcher_web/lib/url_launcher_web.dart index 76e15330d21b..81d1418d9c25 100644 --- a/packages/url_launcher/url_launcher_web/lib/url_launcher_web.dart +++ b/packages/url_launcher/url_launcher_web/lib/url_launcher_web.dart @@ -47,11 +47,13 @@ class UrlLauncherPlugin extends UrlLauncherPlatform { /// Registers this class as the default instance of [UrlLauncherPlatform]. static void registerWith(Registrar registrar) { UrlLauncherPlatform.instance = UrlLauncherPlugin(); - linkDelegate = (LinkInfo linkInfo) => WebLinkDelegate(linkInfo); // ignore: undefined_prefixed_name ui.platformViewRegistry.registerViewFactory(linkViewType, linkViewFactory); } + @override + LinkDelegate linkDelegate = (LinkInfo linkInfo) => WebLinkDelegate(linkInfo); + /// Opens the given [url] in the specified [webOnlyWindowName]. /// /// Returns the newly created window. From 082bbbdd429ce047b90c98e8a1c080db200d62ef Mon Sep 17 00:00:00 2001 From: Mouad Debbar Date: Sun, 18 Oct 2020 02:27:57 -0700 Subject: [PATCH 4/8] more changes --- .../url_launcher/url_launcher_web/lib/url_launcher_web.dart | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/url_launcher/url_launcher_web/lib/url_launcher_web.dart b/packages/url_launcher/url_launcher_web/lib/url_launcher_web.dart index 81d1418d9c25..a51bf8cbfe4a 100644 --- a/packages/url_launcher/url_launcher_web/lib/url_launcher_web.dart +++ b/packages/url_launcher/url_launcher_web/lib/url_launcher_web.dart @@ -52,7 +52,9 @@ class UrlLauncherPlugin extends UrlLauncherPlatform { } @override - LinkDelegate linkDelegate = (LinkInfo linkInfo) => WebLinkDelegate(linkInfo); + LinkDelegate get linkDelegate { + return (LinkInfo linkInfo) => WebLinkDelegate(linkInfo); + } /// Opens the given [url] in the specified [webOnlyWindowName]. /// From b653731e146a40c2d83576d33e295a138891a99a Mon Sep 17 00:00:00 2001 From: Mouad Debbar Date: Tue, 20 Oct 2020 11:23:09 -0700 Subject: [PATCH 5/8] add test + remove layer piercing --- .../url_launcher_web/lib/src/link.dart | 93 +++++++++---------- .../url_launcher_web_integration.dart | 88 ++++++++++++++++++ 2 files changed, 133 insertions(+), 48 deletions(-) diff --git a/packages/url_launcher/url_launcher_web/lib/src/link.dart b/packages/url_launcher/url_launcher_web/lib/src/link.dart index 05fde311012c..8fac399c9a4e 100644 --- a/packages/url_launcher/url_launcher_web/lib/src/link.dart +++ b/packages/url_launcher/url_launcher_web/lib/src/link.dart @@ -14,9 +14,6 @@ import 'package:flutter/services.dart'; import 'package:url_launcher_platform_interface/link.dart'; -/// Signature for a function that handles a mouse click event. -typedef ClickListener = void Function(html.MouseEvent); - /// The unique identifier for the view type to be used for link platform views. const String linkViewType = '__url_launcher::link'; @@ -62,29 +59,8 @@ class WebLinkDelegateState extends State { } Future _followLink() { - final Completer completer = Completer(); - LinkViewController.registerHitTest( - _controller, - onClick: (html.MouseEvent event) { - completer.complete(_onDomClick(event)); - }, - ); - return completer.future; - } - - Future _onDomClick(html.MouseEvent event) { - if (!widget.link.uri.hasScheme) { - // A uri that doesn't have a scheme is an internal route name. In this - // case, we push it via Flutter's navigation system instead of letting the - // browser handle it. - event.preventDefault(); - final String routeName = widget.link.uri.toString(); - return pushRouteNameToFramework(context, routeName); - } - - // External links will be handled by the browser, so we don't have to do - // anything. - return Future.value(null); + LinkViewController.registerHitTest(_controller); + return Future.value(); } @override @@ -98,7 +74,12 @@ class WebLinkDelegateState extends State { Positioned.fill( child: PlatformViewLink( viewType: linkViewType, - onCreatePlatformView: _createController, + onCreatePlatformView: (PlatformViewCreationParams params) { + _controller = LinkViewController.fromParams(params, context); + return _controller + ..setUri(widget.link.uri) + ..setTarget(widget.link.target); + }, surfaceFactory: (BuildContext context, PlatformViewController controller) { return PlatformViewSurface( @@ -113,22 +94,12 @@ class WebLinkDelegateState extends State { ], ); } - - LinkViewController _createController(PlatformViewCreationParams params) { - _controller = LinkViewController(params.id); - _controller._initialize().then((_) { - params.onPlatformViewCreated(params.id); - }); - return _controller - ..setUri(widget.link.uri) - ..setTarget(widget.link.target); - } } /// Controls link views. class LinkViewController extends PlatformViewController { /// Creates a [LinkViewController] instance with the unique [viewId]. - LinkViewController(this.viewId) { + LinkViewController(this.viewId, this.context) { if (_instances.isEmpty) { // This is the first controller being created, attach the global click // listener. @@ -137,6 +108,20 @@ class LinkViewController extends PlatformViewController { _instances[viewId] = this; } + /// Creates and initializes a [LinkViewController] instance with the given + /// platform view [params]. + factory LinkViewController.fromParams( + PlatformViewCreationParams params, + BuildContext context, + ) { + final int viewId = params.id; + final LinkViewController controller = LinkViewController(viewId, context); + controller._initialize().then((_) { + params.onPlatformViewCreated(viewId); + }); + return controller; + } + static Map _instances = {}; static html.Element _viewFactory(int viewId) { @@ -144,7 +129,6 @@ class LinkViewController extends PlatformViewController { } static int _hitTestedViewId; - static ClickListener _hitTestedClickCallback; static StreamSubscription _clickSubscribtion; @@ -161,23 +145,21 @@ class LinkViewController extends PlatformViewController { /// /// The [onClick] callback is invoked when the anchor element receives a /// `click` from the browser. - static void registerHitTest( - LinkViewController controller, { - @required ClickListener onClick, - }) { + static void registerHitTest(LinkViewController controller) { _hitTestedViewId = controller.viewId; - _hitTestedClickCallback = onClick; } /// Removes all information about previously registered hit tests. static void unregisterHitTest() { _hitTestedViewId = null; - _hitTestedClickCallback = null; } @override final int viewId; + /// The context of the [Link] widget that created this controller. + final BuildContext context; + html.Element _element; bool get _isInitialized => _element != null; @@ -198,19 +180,34 @@ class LinkViewController extends PlatformViewController { void _onDomClick(html.MouseEvent event) { final bool isHitTested = _hitTestedViewId == viewId; - if (isHitTested) { - _hitTestedClickCallback(event); - } else { + if (!isHitTested) { // There was no hit test registered for this click. This means the click // landed on the anchor element but not on the underlying widget. In this // case, we prevent the browser from following the click. event.preventDefault(); + return; + } + + if (_uri.hasScheme) { + // External links will be handled by the browser, so we don't have to do + // anything. + return; } + + // A uri that doesn't have a scheme is an internal route name. In this + // case, we push it via Flutter's navigation system instead of letting the + // browser handle it. + event.preventDefault(); + final String routeName = _uri.toString(); + pushRouteNameToFramework(context, routeName); } + Uri _uri; + /// Set the [Uri] value for this link. void setUri(Uri uri) { assert(_isInitialized); + _uri = uri; if (uri == null) { _element.removeAttribute('href'); } else { diff --git a/packages/url_launcher/url_launcher_web/test/test_driver/url_launcher_web_integration.dart b/packages/url_launcher/url_launcher_web/test/test_driver/url_launcher_web_integration.dart index d0dd6e38ee46..4d103443deb9 100644 --- a/packages/url_launcher/url_launcher_web/test/test_driver/url_launcher_web_integration.dart +++ b/packages/url_launcher/url_launcher_web/test/test_driver/url_launcher_web_integration.dart @@ -3,8 +3,12 @@ // found in the LICENSE file. import 'dart:html' as html; +import 'dart:js_util'; +import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:url_launcher_platform_interface/link.dart'; import 'package:url_launcher_web/url_launcher_web.dart'; +import 'package:url_launcher_web/src/link.dart'; import 'package:mockito/mockito.dart'; import 'package:integration_test/integration_test.dart'; @@ -228,4 +232,88 @@ void main() { }); }); }); + + group('link', () { + testWidgets('creates anchor with correct attributes', + (WidgetTester tester) async { + final Uri uri = Uri.parse('http://foobar/example?q=1'); + await tester.pumpWidget(Directionality( + textDirection: TextDirection.ltr, + child: WebLinkDelegate(TestLinkInfo( + uri: uri, + target: LinkTarget.blank, + builder: (BuildContext context, FollowLink followLink) { + return Container(width: 100, height: 100); + }, + )), + )); + // Platform view creation happens asynchronously. + await tester.pumpAndSettle(); + + final html.Element anchor = _findSingleAnchor(); + expect(anchor.getAttribute('href'), uri.toString()); + expect(anchor.getAttribute('target'), '_blank'); + + final Uri uri2 = Uri.parse('http://foobar2/example?q=2'); + await tester.pumpWidget(Directionality( + textDirection: TextDirection.ltr, + child: WebLinkDelegate(TestLinkInfo( + uri: uri2, + target: LinkTarget.self, + builder: (BuildContext context, FollowLink followLink) { + return Container(width: 100, height: 100); + }, + )), + )); + await tester.pumpAndSettle(); + + // Check that the same anchor has been updated. + expect(anchor.getAttribute('href'), uri2.toString()); + expect(anchor.getAttribute('target'), '_self'); + }); + }); +} + +html.Element _findSingleAnchor() { + final List foundAnchors = []; + for (final html.Element anchor in html.document.querySelectorAll('a')) { + if (hasProperty(anchor, linkViewIdProperty)) { + foundAnchors.add(anchor); + } + } + + // Search inside platform views with shadow roots as well. + for (final html.Element platformView + in html.document.querySelectorAll('flt-platform-view')) { + final html.ShadowRoot shadowRoot = platformView.shadowRoot; + if (shadowRoot != null) { + for (final html.Element anchor in shadowRoot.querySelectorAll('a')) { + if (hasProperty(anchor, linkViewIdProperty)) { + foundAnchors.add(anchor); + } + } + } + } + + return foundAnchors.single; +} + +class TestLinkInfo extends LinkInfo { + @override + final LinkWidgetBuilder builder; + + @override + final Uri uri; + + @override + final LinkTarget target; + + @override + bool get isDisabled => uri == null; + + TestLinkInfo({ + @required this.uri, + @required this.target, + @required this.builder, + }); } From b6a51f78a70ed14eb7194040267bb0d28082097b Mon Sep 17 00:00:00 2001 From: Mouad Debbar Date: Tue, 20 Oct 2020 11:31:05 -0700 Subject: [PATCH 6/8] add rel attribute to all links --- packages/url_launcher/url_launcher_web/lib/src/link.dart | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/url_launcher/url_launcher_web/lib/src/link.dart b/packages/url_launcher/url_launcher_web/lib/src/link.dart index 8fac399c9a4e..06fd2c4d561b 100644 --- a/packages/url_launcher/url_launcher_web/lib/src/link.dart +++ b/packages/url_launcher/url_launcher_web/lib/src/link.dart @@ -171,6 +171,10 @@ class LinkViewController extends PlatformViewController { ..display = 'block' ..cursor = 'unset'; + // This is recommended on MDN: + // - https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#attr-target + _element.setAttribute('rel', 'noreferrer noopener'); + final Map args = { 'id': viewId, 'viewType': linkViewType, From 78cd34d004e84efe30d99c6c833d58a14347f9b9 Mon Sep 17 00:00:00 2001 From: Mouad Debbar Date: Tue, 20 Oct 2020 11:38:21 -0700 Subject: [PATCH 7/8] remove incorrect comment --- packages/url_launcher/url_launcher_web/lib/src/link.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/url_launcher/url_launcher_web/lib/src/link.dart b/packages/url_launcher/url_launcher_web/lib/src/link.dart index 06fd2c4d561b..d4cbbe270b99 100644 --- a/packages/url_launcher/url_launcher_web/lib/src/link.dart +++ b/packages/url_launcher/url_launcher_web/lib/src/link.dart @@ -257,7 +257,6 @@ class LinkViewController extends PlatformViewController { if (_instances.isEmpty) { await _clickSubscribtion.cancel(); } - // Asynchronously dispose this view. await SystemChannels.platform_views.invokeMethod('dispose', viewId); } } From 471353dabc4ca8313507cdb48043ae6460f9303c Mon Sep 17 00:00:00 2001 From: Mouad Debbar Date: Tue, 20 Oct 2020 14:32:51 -0700 Subject: [PATCH 8/8] type + analysis options --- .../url_launcher_web/analysis_options.yaml | 10 ++++++++++ .../url_launcher/url_launcher_web/lib/src/link.dart | 6 +++--- .../url_launcher_web/lib/url_launcher_web.dart | 1 - script/incremental_build.sh | 1 + 4 files changed, 14 insertions(+), 4 deletions(-) create mode 100644 packages/url_launcher/url_launcher_web/analysis_options.yaml diff --git a/packages/url_launcher/url_launcher_web/analysis_options.yaml b/packages/url_launcher/url_launcher_web/analysis_options.yaml new file mode 100644 index 000000000000..443b16551ec9 --- /dev/null +++ b/packages/url_launcher/url_launcher_web/analysis_options.yaml @@ -0,0 +1,10 @@ +# This is a temporary file to allow us to unblock the flutter/plugins repo CI. +# It disables some of lints that were disabled inline. Disabling lints inline +# is no longer possible, so this file is required. +# TODO(ditman) https://github.com/flutter/flutter/issues/55000 (clean this up) + +include: ../../../analysis_options.yaml + +analyzer: + errors: + undefined_prefixed_name: ignore diff --git a/packages/url_launcher/url_launcher_web/lib/src/link.dart b/packages/url_launcher/url_launcher_web/lib/src/link.dart index d4cbbe270b99..e8a6d68348bb 100644 --- a/packages/url_launcher/url_launcher_web/lib/src/link.dart +++ b/packages/url_launcher/url_launcher_web/lib/src/link.dart @@ -103,7 +103,7 @@ class LinkViewController extends PlatformViewController { if (_instances.isEmpty) { // This is the first controller being created, attach the global click // listener. - _clickSubscribtion = html.window.onClick.listen(_onGlobalClick); + _clickSubscription = html.window.onClick.listen(_onGlobalClick); } _instances[viewId] = this; } @@ -130,7 +130,7 @@ class LinkViewController extends PlatformViewController { static int _hitTestedViewId; - static StreamSubscription _clickSubscribtion; + static StreamSubscription _clickSubscription; static void _onGlobalClick(html.MouseEvent event) { final int viewId = getViewIdFromTarget(event); @@ -255,7 +255,7 @@ class LinkViewController extends PlatformViewController { assert(_instances[viewId] == this); _instances.remove(viewId); if (_instances.isEmpty) { - await _clickSubscribtion.cancel(); + await _clickSubscription.cancel(); } await SystemChannels.platform_views.invokeMethod('dispose', viewId); } diff --git a/packages/url_launcher/url_launcher_web/lib/url_launcher_web.dart b/packages/url_launcher/url_launcher_web/lib/url_launcher_web.dart index a51bf8cbfe4a..e7367b3a2f6d 100644 --- a/packages/url_launcher/url_launcher_web/lib/url_launcher_web.dart +++ b/packages/url_launcher/url_launcher_web/lib/url_launcher_web.dart @@ -47,7 +47,6 @@ class UrlLauncherPlugin extends UrlLauncherPlatform { /// Registers this class as the default instance of [UrlLauncherPlatform]. static void registerWith(Registrar registrar) { UrlLauncherPlatform.instance = UrlLauncherPlugin(); - // ignore: undefined_prefixed_name ui.platformViewRegistry.registerViewFactory(linkViewType, linkViewFactory); } diff --git a/script/incremental_build.sh b/script/incremental_build.sh index 30c166b4c666..8e9cf34b1cda 100755 --- a/script/incremental_build.sh +++ b/script/incremental_build.sh @@ -24,6 +24,7 @@ CUSTOM_ANALYSIS_PLUGINS=( "camera" "video_player/video_player_web" "google_maps_flutter/google_maps_flutter_web" + "url_launcher/url_launcher_web" ) # Comma-separated string of the list above readonly CUSTOM_FLAG=$(IFS=, ; echo "${CUSTOM_ANALYSIS_PLUGINS[*]}")