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: 2 additions & 2 deletions example/ios/Podfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 3 additions & 1 deletion example/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,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 @@ -81,7 +82,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 @@ -231,6 +232,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
7 changes: 7 additions & 0 deletions lib/flutter_html.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -61,6 +62,7 @@ class Html extends StatelessWidget {
this.navigationDelegateForIframe,
}) : document = null,
assert (data != null),
anchorKey = GlobalKey(),
super(key: key);

Html.fromDom({
Expand All @@ -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;

Expand Down Expand Up @@ -138,6 +144,7 @@ class Html extends StatelessWidget {
return Container(
width: width,
child: HtmlParser(
key: anchorKey,
htmlData: doc,
onLinkTap: onLinkTap,
onImageTap: onImageTap,
Expand Down
42 changes: 36 additions & 6 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> tagsList;
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.tagsList,
required this.navigationDelegateForIframe,
});
}): this._onAnchorTap = key != 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 @@ -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(
Expand All @@ -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<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 +764,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 Expand Up @@ -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) {
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}';
}
}
5 changes: 4 additions & 1 deletion lib/src/interactable_element.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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:
Expand All @@ -43,6 +45,7 @@ InteractableElement parseInteractableElement(
node: element,
href: '',
style: Style(),
elementId: "[[No ID]]"
);
}
}
10 changes: 7 additions & 3 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 All @@ -14,8 +15,9 @@ abstract class LayoutElement extends StyledElement {
LayoutElement({
String name = "[[No Name]]",
required List<StyledElement> 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);
}
Expand All @@ -25,11 +27,12 @@ class TableLayoutElement extends LayoutElement {
required String name,
required List<StyledElement> 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(
Expand Down Expand Up @@ -263,7 +266,7 @@ class DetailsContentElement extends LayoutElement {
required List<StyledElement> 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) {
Expand All @@ -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(
Expand Down
Loading