[web] Implement Link for web (#3155)

diff --git a/packages/url_launcher/url_launcher_web/CHANGELOG.md b/packages/url_launcher/url_launcher_web/CHANGELOG.md
index 456d458..e7abd13 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 0000000..443b165
--- /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 0000000..e8a6d68
--- /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<WebLinkDelegate> {
+  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<void> _followLink() {
+    LinkViewController.registerHitTest(_controller);
+    return Future<void>.value();
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return Stack(
+      children: <Widget>[
+        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<Factory<OneSequenceGestureRecognizer>>(),
+                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<int, LinkViewController> _instances = <int, LinkViewController>{};
+
+  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<void> _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<String, dynamic> args = <String, dynamic>{
+      'id': viewId,
+      'viewType': linkViewType,
+    };
+    await SystemChannels.platform_views.invokeMethod<void>('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<void> clearFocus() async {
+    // Currently this does nothing on Flutter Web.
+    // TODO(het): Implement this. See https://github.com/flutter/flutter/issues/39496
+  }
+
+  @override
+  Future<void> 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<void> dispose() async {
+    if (_isInitialized) {
+      assert(_instances[viewId] == this);
+      _instances.remove(viewId);
+      if (_instances.isEmpty) {
+        await _clickSubscription.cancel();
+      }
+      await SystemChannels.platform_views.invokeMethod<void>('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 093e06a..e7367b3 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 @@
   /// 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 957b257..7ae84cd 100644
--- a/packages/url_launcher/url_launcher_web/pubspec.yaml
+++ b/packages/url_launcher/url_launcher_web/pubspec.yaml
@@ -4,7 +4,7 @@
 # 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 @@
         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 d0dd6e3..4d10344 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 @@
       });
     });
   });
+
+  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<html.Element> foundAnchors = <html.Element>[];
+  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 30c166b..8e9cf34 100755
--- a/script/incremental_build.sh
+++ b/script/incremental_build.sh
@@ -24,6 +24,7 @@
   "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[*]}")