Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support inner links #555

Merged
merged 10 commits into from
Apr 29, 2021
4 changes: 3 additions & 1 deletion example/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ class MyHomePage extends StatefulWidget {
}

const htmlData = r"""
<p id='top'><a href='#bottom'>Scroll to bottom</a></p>
<h1>Header 1</h1>
<h2>Header 2</h2>
<h3>Header 3</h3>
Expand Down Expand Up @@ -84,7 +85,7 @@ const htmlData = r"""
<h3>Custom Element Support (inline: <bird></bird> and as block):</h3>
<flutter></flutter>
<flutter horizontal></flutter>
<h3>SVG support:</h3>
<h3 id='middle'>SVG support:</h3>
<svg id='svg1' viewBox='0 0 100 100' xmlns='http://www.w3.org/2000/svg'>
<circle r="32" cx="35" cy="65" fill="#F00" opacity="0.5"/>
<circle r="32" cx="65" cy="65" fill="#0F0" opacity="0.5"/>
Expand Down Expand Up @@ -234,6 +235,7 @@ const htmlData = r"""
</math>
<h3>Tex Support with the custom tex tag:</h3>
<tex>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)</tex>
<p id='bottom'><a href='#top'>Scroll to top</a></p>
""";

class _MyHomePageState extends State<MyHomePage> {
Expand Down
10 changes: 5 additions & 5 deletions lib/flutter_html.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
library flutter_html;

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/style.dart';
Expand Down Expand Up @@ -48,7 +49,7 @@ class Html extends StatelessWidget {
this.navigationDelegateForIframe,
}) : document = null,
assert (data != null),
super(key: key);
super(key: key ?? UniqueKey());

Html.fromDom({
Key? key,
Expand All @@ -65,7 +66,7 @@ class Html extends StatelessWidget {
this.navigationDelegateForIframe,
}) : data = null,
assert(document != null),
super(key: key);
super(key: key ?? UniqueKey());

/// The HTML data passed to the widget as a String
final String? data;
Expand Down Expand Up @@ -118,6 +119,7 @@ class Html extends StatelessWidget {
return Container(
width: width,
child: HtmlParser(
key: key,
htmlData: doc,
onLinkTap: onLinkTap,
onImageTap: onImageTap,
Expand All @@ -126,9 +128,7 @@ class Html extends StatelessWidget {
shrinkWrap: shrinkWrap,
style: style,
customRender: customRender,
imageRenders: {}
..addAll(customImageRenders)
..addAll(defaultImageRenders),
imageRenders: {}..addAll(customImageRenders)..addAll(defaultImageRenders),
blacklistedElements: blacklistedElements,
navigationDelegateForIframe: navigationDelegateForIframe,
),
Expand Down
35 changes: 31 additions & 4 deletions lib/html_parser.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;
Expand All @@ -45,8 +47,10 @@ class HtmlParser extends StatelessWidget {
final Map<ImageSourceMatcher, ImageRender> imageRenders;
final List<String> blacklistedElements;
final NavigationDelegate? navigationDelegateForIframe;
final OnTap? _onAnchorTap;

HtmlParser({
required this.key,
required this.htmlData,
required this.onLinkTap,
required this.onImageTap,
Expand All @@ -58,7 +62,7 @@ class HtmlParser extends StatelessWidget {
required this.imageRenders,
required this.blacklistedElements,
required this.navigationDelegateForIframe,
});
}): this._onAnchorTap = key != null && onLinkTap != null ? _handleAnchorTap(key, onLinkTap): null, super(key: key);

@override
Widget build(BuildContext context) {
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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;
},
),
},
Expand Down Expand Up @@ -429,6 +442,18 @@ class HtmlParser extends StatelessWidget {
}
}

static OnTap _handleAnchorTap(Key key, OnTap onLinkTap) =>
(String? url, RenderContext context, Map<String, String> 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
Expand Down Expand Up @@ -738,19 +763,21 @@ 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<InlineSpan>? children;
final Style style;
final RenderContext newContext;
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 _) {
Expand Down
34 changes: 34 additions & 0 deletions lib/src/anchor.dart
Original file line number Diff line number Diff line change
@@ -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}';
}
}
3 changes: 3 additions & 0 deletions lib/src/layout_element.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -30,6 +31,7 @@ class TableLayoutElement extends LayoutElement {
@override
Widget toWidget(RenderContext context) {
return Container(
key: AnchorKey.of(context.parser.key, this),
margin: style.margin,
padding: style.padding,
decoration: BoxDecoration(
Expand Down Expand Up @@ -279,6 +281,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(
Expand Down
6 changes: 6 additions & 0 deletions lib/src/replaced_element.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -82,6 +83,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<MultipleTapGestureRecognizer>(
Expand Down Expand Up @@ -118,6 +120,7 @@ class AudioContentElement extends ReplacedElement {
@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,
Expand Down Expand Up @@ -167,6 +170,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(
Expand Down Expand Up @@ -205,6 +209,7 @@ class SvgContentElement extends ReplacedElement {
Widget toWidget(RenderContext context) {
return SvgPicture.string(
data,
key: AnchorKey.of(context.parser.key, this),
width: width,
height: height,
);
Expand Down Expand Up @@ -260,6 +265,7 @@ class RubyElement extends ReplacedElement {
}
});
return Row(
key: AnchorKey.of(context.parser.key, this),
crossAxisAlignment: CrossAxisAlignment.end,
textBaseline: TextBaseline.alphabetic,
mainAxisSize: MainAxisSize.min,
Expand Down