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/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 new file mode 100644 index 000000000000..e8a6d68348bb --- /dev/null +++ b/packages/url_launcher/url_launcher_web/lib/src/link.dart @@ -0,0 +1,295 @@ +// 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'; + +/// The unique identifier for the view type to be used for link platform views. +const String linkViewType = '__url_launcher::link'; + +/// The name of the property used to set the viewId on the DOM element. +const String linkViewIdProperty = '__url_launcher::link::viewId'; + +/// Signature for a function that takes a unique [id] and creates an HTML element. +typedef HtmlViewFactory = html.Element Function(int viewId); + +/// Factory that returns the link DOM element for each unique view id. +HtmlViewFactory 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(); +} + +/// 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); + } + if (widget.link.target != oldWidget.link.target) { + _controller?.setTarget(widget.link.target); + } + } + + Future _followLink() { + LinkViewController.registerHitTest(_controller); + return Future.value(); + } + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + widget.link.builder( + context, + widget.link.isDisabled ? null : _followLink, + ), + Positioned.fill( + child: PlatformViewLink( + viewType: linkViewType, + 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( + controller: controller, + gestureRecognizers: + Set>(), + hitTestBehavior: PlatformViewHitTestBehavior.transparent, + ); + }, + ), + ), + ], + ); + } +} + +/// Controls link views. +class LinkViewController extends PlatformViewController { + /// Creates a [LinkViewController] instance with the unique [viewId]. + LinkViewController(this.viewId, this.context) { + if (_instances.isEmpty) { + // This is the first controller being created, attach the global click + // listener. + _clickSubscription = html.window.onClick.listen(_onGlobalClick); + } + _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) { + return _instances[viewId]?._element; + } + + static int _hitTestedViewId; + + static StreamSubscription _clickSubscription; + + 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 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) { + _hitTestedViewId = controller.viewId; + } + + /// Removes all information about previously registered hit tests. + static void unregisterHitTest() { + _hitTestedViewId = 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; + + Future _initialize() async { + _element = html.Element.tag('a'); + setProperty(_element, linkViewIdProperty, viewId); + _element.style + ..opacity = '0' + ..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, + }; + await SystemChannels.platform_views.invokeMethod('create', args); + } + + void _onDomClick(html.MouseEvent event) { + final bool isHitTested = _hitTestedViewId == viewId; + 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 { + _element.setAttribute('href', uri.toString()); + } + } + + /// Set the [LinkTarget] value for this link. + 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 _clickSubscription.cancel(); + } + await SystemChannels.platform_views.invokeMethod('dispose', viewId); + } + } +} + +/// 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, linkViewIdProperty); + } + return null; +} + +/// 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)) { + return target; + } + if (target.shadowRoot != null) { + final html.Element child = target.shadowRoot.lastChild; + if (isLinkElement(child)) { + return child; + } + } + return null; +} + +/// 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 093e06a4d8ed..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 @@ -4,11 +4,15 @@ 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 'src/link.dart'; import 'src/third_party/platform_detect/browser.dart'; const _safariTargetTopSchemes = { @@ -43,6 +47,12 @@ class UrlLauncherPlugin extends UrlLauncherPlatform { /// Registers this class as the default instance of [UrlLauncherPlatform]. static void registerWith(Registrar registrar) { UrlLauncherPlatform.instance = UrlLauncherPlugin(); + ui.platformViewRegistry.registerViewFactory(linkViewType, linkViewFactory); + } + + @override + LinkDelegate get linkDelegate { + return (LinkInfo linkInfo) => WebLinkDelegate(linkInfo); } /// 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: 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, + }); } 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[*]}")