Skip to content

Commit

Permalink
Merge pull request #555 from vrtdev/feature/268-inner-links
Browse files Browse the repository at this point in the history
Support inner links
  • Loading branch information
erickok authored Apr 29, 2021
2 parents 4b153dd + 4866bef commit 0874d55
Show file tree
Hide file tree
Showing 11 changed files with 114 additions and 27 deletions.
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

0 comments on commit 0874d55

Please sign in to comment.