diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index 7503100e5b..e3543a279d 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -26,8 +26,8 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: Flutter: 434fef37c0980e73bb6479ef766c45957d4b510c video_player: 9cc823b1d9da7e8427ee591e8438bfbcde500e6e - wakelock: bfc7955c418d0db797614075aabbc58a39ab5107 - webview_flutter: d2b4d6c66968ad042ad94cbb791f5b72b4678a96 + wakelock: b0843b2479edbf6504d8d262c2959446f35373aa + webview_flutter: 9f491a9b5a66f2573946a389b2677987b0ff8c0b PODFILE CHECKSUM: 8e679eca47255a8ca8067c4c67aab20e64cb974d diff --git a/example/lib/main.dart b/example/lib/main.dart index 6043fdde83..665fc768f7 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -27,6 +27,7 @@ class MyHomePage extends StatefulWidget { } const htmlData = r""" +

Scroll to bottom

Header 1

Header 2

Header 3

@@ -81,7 +82,7 @@ const htmlData = r"""

Custom Element Support (inline: and as block):

-

SVG support:

+

SVG support:

@@ -231,6 +232,7 @@ const htmlData = r"""

Tex Support with the custom tex tag:

i\hbar\frac{\partial}{\partial t}\Psi(\vec x,t) = -\frac{\hbar}{2m}\nabla^2\Psi(\vec x,t)+ V(\vec x)\Psi(\vec x,t) +

Scroll to top

"""; class _MyHomePageState extends State { diff --git a/lib/flutter_html.dart b/lib/flutter_html.dart index d268f614a7..2397fc0410 100644 --- a/lib/flutter_html.dart +++ b/lib/flutter_html.dart @@ -13,6 +13,7 @@ export 'package:flutter_html/src/styled_element.dart'; export 'package:flutter_html/src/interactable_element.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; import 'package:flutter_html/html_parser.dart'; import 'package:flutter_html/image_render.dart'; import 'package:flutter_html/src/html_elements.dart'; @@ -61,6 +62,7 @@ class Html extends StatelessWidget { this.navigationDelegateForIframe, }) : document = null, assert (data != null), + anchorKey = GlobalKey(), super(key: key); Html.fromDom({ @@ -78,8 +80,12 @@ class Html extends StatelessWidget { this.navigationDelegateForIframe, }) : data = null, assert(document != null), + anchorKey = GlobalKey(), super(key: key); + /// A unique key for this Html widget to ensure uniqueness of anchors + final Key anchorKey; + /// The HTML data passed to the widget as a String final String? data; @@ -138,6 +144,7 @@ class Html extends StatelessWidget { return Container( width: width, child: HtmlParser( + key: anchorKey, htmlData: doc, onLinkTap: onLinkTap, onImageTap: onImageTap, diff --git a/lib/html_parser.dart b/lib/html_parser.dart index e14d393bfd..203fb2806d 100644 --- a/lib/html_parser.dart +++ b/lib/html_parser.dart @@ -7,6 +7,7 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_html/flutter_html.dart'; import 'package:flutter_html/image_render.dart'; +import 'package:flutter_html/src/anchor.dart'; import 'package:flutter_html/src/css_parser.dart'; import 'package:flutter_html/src/html_elements.dart'; import 'package:flutter_html/src/layout_element.dart'; @@ -33,6 +34,7 @@ typedef CustomRender = dynamic Function( ); class HtmlParser extends StatelessWidget { + final Key? key; final dom.Document htmlData; final OnTap? onLinkTap; final OnTap? onImageTap; @@ -45,8 +47,10 @@ class HtmlParser extends StatelessWidget { final Map imageRenders; final List tagsList; final NavigationDelegate? navigationDelegateForIframe; + final OnTap? _onAnchorTap; HtmlParser({ + required this.key, required this.htmlData, required this.onLinkTap, required this.onImageTap, @@ -58,7 +62,7 @@ class HtmlParser extends StatelessWidget { required this.imageRenders, required this.tagsList, required this.navigationDelegateForIframe, - }); + }): this._onAnchorTap = key != null ? _handleAnchorTap(key, onLinkTap): null, super(key: key); @override Widget build(BuildContext context) { @@ -250,6 +254,7 @@ class HtmlParser extends StatelessWidget { final render = customRender[tree.name]!.call( newContext, ContainerSpan( + key: AnchorKey.of(key, tree), newContext: newContext, style: tree.style, shrinkWrap: context.parser.shrinkWrap, @@ -262,6 +267,7 @@ class HtmlParser extends StatelessWidget { ? render : WidgetSpan( child: ContainerSpan( + key: AnchorKey.of(key, tree), newContext: newContext, style: tree.style, shrinkWrap: context.parser.shrinkWrap, @@ -275,6 +281,7 @@ class HtmlParser extends StatelessWidget { if (tree.style.display == Display.BLOCK) { return WidgetSpan( child: ContainerSpan( + key: AnchorKey.of(key, tree), newContext: newContext, style: tree.style, shrinkWrap: context.parser.shrinkWrap, @@ -293,6 +300,7 @@ class HtmlParser extends StatelessWidget { return WidgetSpan( child: ContainerSpan( + key: AnchorKey.of(key, tree), newContext: newContext, style: tree.style, shrinkWrap: context.parser.shrinkWrap, @@ -357,18 +365,23 @@ class HtmlParser extends StatelessWidget { : childStyle.merge(childSpan.style)), semanticsLabel: childSpan.semanticsLabel, recognizer: TapGestureRecognizer() - ..onTap = () => onLinkTap?.call(tree.href, context, tree.attributes, tree.element), + ..onTap = + _onAnchorTap != null ? () => _onAnchorTap!(tree.href, context, tree.attributes, tree.element) : null, ); } else { return WidgetSpan( child: RawGestureDetector( + key: AnchorKey.of(key, tree), gestures: { MultipleTapGestureRecognizer: GestureRecognizerFactoryWithHandlers< MultipleTapGestureRecognizer>( () => MultipleTapGestureRecognizer(), (instance) { - instance..onTap = () => onLinkTap?.call(tree.href, context, tree.attributes, tree.element); + instance + ..onTap = _onAnchorTap != null + ? () => _onAnchorTap!(tree.href, context, tree.attributes, tree.element) + : null; }, ), }, @@ -406,6 +419,7 @@ class HtmlParser extends StatelessWidget { //Requires special layout features not available in the TextStyle API. return WidgetSpan( child: Transform.translate( + key: AnchorKey.of(key, tree), offset: Offset(0, verticalOffset), child: StyledText( textSpan: TextSpan( @@ -424,11 +438,23 @@ class HtmlParser extends StatelessWidget { return TextSpan( style: newContext.style.generateTextStyle(), children: - tree.children.map((tree) => parseTree(newContext, tree)).toList(), + tree.children.map((tree) => parseTree(newContext, tree)).toList(), ); } } + static OnTap _handleAnchorTap(Key key, OnTap? onLinkTap) => + (String? url, RenderContext context, Map attributes, dom.Element? element) { + if (url?.startsWith("#") == true) { + final anchorContext = AnchorKey.forId(key, url!.substring(1))?.currentContext; + if (anchorContext != null) { + Scrollable.ensureVisible(anchorContext); + } + return; + } + onLinkTap?.call(url, context, attributes, element); + }; + /// [processWhitespace] removes unnecessary whitespace from the StyledElement tree. /// /// The criteria for determining which whitespace is replaceable is outlined @@ -738,6 +764,7 @@ class RenderContext { /// A [ContainerSpan] can have a border, background color, height, width, padding, and margin /// and can represent either an INLINE or BLOCK-level element. class ContainerSpan extends StatelessWidget { + final AnchorKey? key; final Widget? child; final List? children; final Style style; @@ -745,12 +772,13 @@ class ContainerSpan extends StatelessWidget { final bool shrinkWrap; ContainerSpan({ + this.key, this.child, this.children, required this.style, required this.newContext, this.shrinkWrap = false, - }); + }): super(key: key); @override Widget build(BuildContext _) { @@ -782,13 +810,15 @@ class StyledText extends StatelessWidget { final Style style; final double textScaleFactor; final RenderContext renderContext; + final AnchorKey? key; const StyledText({ required this.textSpan, required this.style, this.textScaleFactor = 1.0, required this.renderContext, - }); + this.key, + }) : super(key: key); @override Widget build(BuildContext context) { diff --git a/lib/src/anchor.dart b/lib/src/anchor.dart new file mode 100644 index 0000000000..5aba894186 --- /dev/null +++ b/lib/src/anchor.dart @@ -0,0 +1,34 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_html/src/styled_element.dart'; + +class AnchorKey extends GlobalKey { + final Key parentKey; + final String id; + + const AnchorKey._(this.parentKey, this.id) : super.constructor(); + + static AnchorKey? of(Key? parentKey, StyledElement? id) { + return forId(parentKey, id?.elementId); + } + + static AnchorKey? forId(Key? parentKey, String? id) { + if (parentKey == null || id == null || id.isEmpty || id == "[[No ID]]") { + return null; + } + return AnchorKey._(parentKey, id); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is AnchorKey && runtimeType == other.runtimeType && parentKey == other.parentKey && id == other.id; + + @override + int get hashCode => parentKey.hashCode ^ id.hashCode; + + @override + String toString() { + return 'AnchorKey{parentKey: $parentKey, id: #$id}'; + } +} diff --git a/lib/src/interactable_element.dart b/lib/src/interactable_element.dart index b3948029b1..504f185406 100644 --- a/lib/src/interactable_element.dart +++ b/lib/src/interactable_element.dart @@ -13,7 +13,8 @@ class InteractableElement extends StyledElement { required Style style, required this.href, required dom.Node node, - }) : super(name: name, children: children, style: style, node: node as dom.Element?); + required String elementId, + }) : super(name: name, children: children, style: style, node: node as dom.Element?, elementId: elementId); } /// A [Gesture] indicates the type of interaction by a user. @@ -34,6 +35,7 @@ InteractableElement parseInteractableElement( textDecoration: TextDecoration.underline, ), node: element, + elementId: element.id ); /// will never be called, just to suppress missing return warning default: @@ -43,6 +45,7 @@ InteractableElement parseInteractableElement( node: element, href: '', style: Style(), + elementId: "[[No ID]]" ); } } \ No newline at end of file diff --git a/lib/src/layout_element.dart b/lib/src/layout_element.dart index 329c87f2f4..4c12ed4c59 100644 --- a/lib/src/layout_element.dart +++ b/lib/src/layout_element.dart @@ -2,6 +2,7 @@ import 'dart:math'; import 'package:flutter/material.dart'; import 'package:flutter_html/html_parser.dart'; +import 'package:flutter_html/src/anchor.dart'; import 'package:flutter_html/src/html_elements.dart'; import 'package:flutter_html/src/styled_element.dart'; import 'package:flutter_html/style.dart'; @@ -14,8 +15,9 @@ abstract class LayoutElement extends StyledElement { LayoutElement({ String name = "[[No Name]]", required List children, + String? elementId, dom.Element? node, - }) : super(name: name, children: children, style: Style(), node: node); + }) : super(name: name, children: children, style: Style(), node: node, elementId: elementId ?? "[[No ID]]"); Widget? toWidget(RenderContext context); } @@ -25,11 +27,12 @@ class TableLayoutElement extends LayoutElement { required String name, required List children, required dom.Element node, - }) : super(name: name, children: children, node: node); + }) : super(name: name, children: children, node: node, elementId: node.id); @override Widget toWidget(RenderContext context) { return Container( + key: AnchorKey.of(context.parser.key, this), margin: style.margin, padding: style.padding, decoration: BoxDecoration( @@ -263,7 +266,7 @@ class DetailsContentElement extends LayoutElement { required List children, required dom.Element node, required this.elementList, - }) : super(name: name, node: node, children: children); + }) : super(name: name, node: node, children: children, elementId: node.id); @override Widget toWidget(RenderContext context) { @@ -279,6 +282,7 @@ class DetailsContentElement extends LayoutElement { } InlineSpan? firstChild = childrenList.isNotEmpty == true ? childrenList.first : null; return ExpansionTile( + key: AnchorKey.of(context.parser.key, this), expandedAlignment: Alignment.centerLeft, title: elementList.isNotEmpty == true && elementList.first.localName == "summary" ? StyledText( textSpan: TextSpan( diff --git a/lib/src/replaced_element.dart b/lib/src/replaced_element.dart index 53e8f2c6eb..0a6a6eaff9 100644 --- a/lib/src/replaced_element.dart +++ b/lib/src/replaced_element.dart @@ -5,6 +5,7 @@ import 'package:chewie_audio/chewie_audio.dart'; import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_html/html_parser.dart'; +import 'package:flutter_html/src/anchor.dart'; import 'package:flutter_html/src/html_elements.dart'; import 'package:flutter_html/src/utils.dart'; import 'package:flutter_html/src/widgets/iframe_unsupported.dart' @@ -27,9 +28,10 @@ abstract class ReplacedElement extends StyledElement { ReplacedElement({ required String name, required Style style, + required String elementId, dom.Element? node, - this.alignment = PlaceholderAlignment.aboveBaseline - }) : super(name: name, children: [], style: style, node: node); + this.alignment = PlaceholderAlignment.aboveBaseline, + }) : super(name: name, children: [], style: style, node: node, elementId: elementId); static List parseMediaSources(List elements) { return elements @@ -52,7 +54,7 @@ class TextContentElement extends ReplacedElement { required this.text, this.node, dom.Element? element, - }) : super(name: "[text]", style: style, node: element); + }) : super(name: "[text]", style: style, node: element, elementId: "[[No ID]]"); @override String toString() { @@ -74,7 +76,7 @@ class ImageContentElement extends ReplacedElement { required this.src, required this.alt, required dom.Element node, - }) : super(name: name, style: Style(), node: node, alignment: PlaceholderAlignment.middle); + }) : super(name: name, style: Style(), node: node, alignment: PlaceholderAlignment.middle, elementId: node.id); @override Widget toWidget(RenderContext context) { @@ -82,6 +84,7 @@ class ImageContentElement extends ReplacedElement { if (entry.key.call(attributes, element)) { final widget = entry.value.call(context, attributes, element); return RawGestureDetector( + key: AnchorKey.of(context.parser.key, this), child: widget, gestures: { MultipleTapGestureRecognizer: GestureRecognizerFactoryWithHandlers( @@ -113,11 +116,12 @@ class AudioContentElement extends ReplacedElement { required this.loop, required this.muted, required dom.Element node, - }) : super(name: name, style: Style(), node: node); + }) : super(name: name, style: Style(), node: node, elementId: node.id); @override Widget toWidget(RenderContext context) { return Container( + key: AnchorKey.of(context.parser.key, this), width: context.style.width ?? 300, height: Theme.of(context.buildContext).platform == TargetPlatform.android ? 48 : 75, @@ -158,7 +162,7 @@ class VideoContentElement extends ReplacedElement { required this.width, required this.height, required dom.Element node, - }) : super(name: name, style: Style(), node: node); + }) : super(name: name, style: Style(), node: node, elementId: node.id); @override Widget toWidget(RenderContext context) { @@ -167,6 +171,7 @@ class VideoContentElement extends ReplacedElement { return AspectRatio( aspectRatio: _width / _height, child: Container( + key: AnchorKey.of(context.parser.key, this), child: Chewie( controller: ChewieController( videoPlayerController: VideoPlayerController.network( @@ -198,13 +203,14 @@ class SvgContentElement extends ReplacedElement { required this.data, required this.width, required this.height, - required dom.Node node, - }) : super(name: name, style: Style(), node: node as dom.Element?); + required dom.Element node, + }) : super(name: name, style: Style(), node: node, elementId: node.id); @override Widget toWidget(RenderContext context) { return SvgPicture.string( data, + key: AnchorKey.of(context.parser.key, this), width: width, height: height, ); @@ -212,7 +218,7 @@ class SvgContentElement extends ReplacedElement { } class EmptyContentElement extends ReplacedElement { - EmptyContentElement({String name = "empty"}) : super(name: name, style: Style()); + EmptyContentElement({String name = "empty"}) : super(name: name, style: Style(), elementId: "[[No ID]]"); @override Widget? toWidget(_) => null; @@ -222,7 +228,7 @@ class RubyElement extends ReplacedElement { dom.Element element; RubyElement({required this.element, String name = "ruby"}) - : super(name: name, alignment: PlaceholderAlignment.middle, style: Style()); + : super(name: name, alignment: PlaceholderAlignment.middle, style: Style(), elementId: element.id); @override Widget toWidget(RenderContext context) { @@ -260,6 +266,7 @@ class RubyElement extends ReplacedElement { } }); return Row( + key: AnchorKey.of(context.parser.key, this), crossAxisAlignment: CrossAxisAlignment.end, textBaseline: TextBaseline.alphabetic, mainAxisSize: MainAxisSize.min, @@ -276,7 +283,7 @@ class MathElement extends ReplacedElement { required this.element, this.texStr, String name = "math", - }) : super(name: name, alignment: PlaceholderAlignment.middle, style: Style()); + }) : super(name: name, alignment: PlaceholderAlignment.middle, style: Style(), elementId: element.id); @override Widget toWidget(RenderContext context) { diff --git a/lib/src/widgets/iframe_mobile.dart b/lib/src/widgets/iframe_mobile.dart index bd9c7e02c8..55223b3478 100644 --- a/lib/src/widgets/iframe_mobile.dart +++ b/lib/src/widgets/iframe_mobile.dart @@ -22,7 +22,7 @@ class IframeContentElement extends ReplacedElement { required this.height, required dom.Element node, required this.navigationDelegate, - }) : super(name: name, style: Style(), node: node); + }) : super(name: name, style: Style(), node: node, elementId: node.id); @override Widget toWidget(RenderContext context) { diff --git a/lib/src/widgets/iframe_unsupported.dart b/lib/src/widgets/iframe_unsupported.dart index f7a74fde3e..4adae1a5d2 100644 --- a/lib/src/widgets/iframe_unsupported.dart +++ b/lib/src/widgets/iframe_unsupported.dart @@ -20,7 +20,7 @@ class IframeContentElement extends ReplacedElement { required this.height, required dom.Element node, required this.navigationDelegate, - }) : super(name: name, style: Style(), node: node); + }) : super(name: name, style: Style(), node: node, elementId: node.id); @override Widget toWidget(RenderContext context) { diff --git a/lib/src/widgets/iframe_web.dart b/lib/src/widgets/iframe_web.dart index 1a9c348461..a70de148da 100644 --- a/lib/src/widgets/iframe_web.dart +++ b/lib/src/widgets/iframe_web.dart @@ -25,7 +25,7 @@ class IframeContentElement extends ReplacedElement { required this.height, required dom.Element node, required this.navigationDelegate, - }) : super(name: name, style: Style(), node: node); + }) : super(name: name, style: Style(), node: node, elementId: node.id); @override Widget toWidget(RenderContext context) {