Skip to content

Commit de22c1c

Browse files
committed
Merge remote-tracking branch 'upstream/master' into feature/200
2 parents ba391f7 + 0874d55 commit de22c1c

File tree

10 files changed

+112
-25
lines changed

10 files changed

+112
-25
lines changed

example/lib/main.dart

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ class MyHomePage extends StatefulWidget {
2727
}
2828

2929
const htmlData = r"""
30+
<p id='top'><a href='#bottom'>Scroll to bottom</a></p>
3031
<h1>Header 1</h1>
3132
<h2>Header 2</h2>
3233
<h3>Header 3</h3>
@@ -81,7 +82,7 @@ const htmlData = r"""
8182
<h3>Custom Element Support (inline: <bird></bird> and as block):</h3>
8283
<flutter></flutter>
8384
<flutter horizontal></flutter>
84-
<h3>SVG support:</h3>
85+
<h3 id='middle'>SVG support:</h3>
8586
<svg id='svg1' viewBox='0 0 100 100' xmlns='http://www.w3.org/2000/svg'>
8687
<circle r="32" cx="35" cy="65" fill="#F00" opacity="0.5"/>
8788
<circle r="32" cx="65" cy="65" fill="#0F0" opacity="0.5"/>
@@ -231,6 +232,7 @@ const htmlData = r"""
231232
</math>
232233
<h3>Tex Support with the custom tex tag:</h3>
233234
<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>
235+
<p id='bottom'><a href='#top'>Scroll to top</a></p>
234236
""";
235237

236238
class _MyHomePageState extends State<MyHomePage> {

lib/flutter_html.dart

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export 'package:flutter_html/src/styled_element.dart';
1313
export 'package:flutter_html/src/interactable_element.dart';
1414

1515
import 'package:flutter/material.dart';
16+
import 'package:flutter/rendering.dart';
1617
import 'package:flutter_html/html_parser.dart';
1718
import 'package:flutter_html/image_render.dart';
1819
import 'package:flutter_html/src/html_elements.dart';
@@ -61,6 +62,7 @@ class Html extends StatelessWidget {
6162
this.navigationDelegateForIframe,
6263
}) : document = null,
6364
assert (data != null),
65+
anchorKey = GlobalKey(),
6466
super(key: key);
6567

6668
Html.fromDom({
@@ -78,8 +80,12 @@ class Html extends StatelessWidget {
7880
this.navigationDelegateForIframe,
7981
}) : data = null,
8082
assert(document != null),
83+
anchorKey = GlobalKey(),
8184
super(key: key);
8285

86+
/// A unique key for this Html widget to ensure uniqueness of anchors
87+
final Key anchorKey;
88+
8389
/// The HTML data passed to the widget as a String
8490
final String? data;
8591

@@ -138,6 +144,7 @@ class Html extends StatelessWidget {
138144
return Container(
139145
width: width,
140146
child: HtmlParser(
147+
key: anchorKey,
141148
htmlData: doc,
142149
onLinkTap: onLinkTap,
143150
onImageTap: onImageTap,

lib/html_parser.dart

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import 'package:flutter/gestures.dart';
77
import 'package:flutter/material.dart';
88
import 'package:flutter_html/flutter_html.dart';
99
import 'package:flutter_html/image_render.dart';
10+
import 'package:flutter_html/src/anchor.dart';
1011
import 'package:flutter_html/src/css_parser.dart';
1112
import 'package:flutter_html/src/html_elements.dart';
1213
import 'package:flutter_html/src/layout_element.dart';
@@ -33,6 +34,7 @@ typedef CustomRender = dynamic Function(
3334
);
3435

3536
class HtmlParser extends StatelessWidget {
37+
final Key? key;
3638
final dom.Document htmlData;
3739
final OnTap? onLinkTap;
3840
final OnTap? onImageTap;
@@ -45,8 +47,10 @@ class HtmlParser extends StatelessWidget {
4547
final Map<ImageSourceMatcher, ImageRender> imageRenders;
4648
final List<String> tagsList;
4749
final NavigationDelegate? navigationDelegateForIframe;
50+
final OnTap? _onAnchorTap;
4851

4952
HtmlParser({
53+
required this.key,
5054
required this.htmlData,
5155
required this.onLinkTap,
5256
required this.onImageTap,
@@ -58,7 +62,7 @@ class HtmlParser extends StatelessWidget {
5862
required this.imageRenders,
5963
required this.tagsList,
6064
required this.navigationDelegateForIframe,
61-
});
65+
}): this._onAnchorTap = key != null ? _handleAnchorTap(key, onLinkTap): null, super(key: key);
6266

6367
@override
6468
Widget build(BuildContext context) {
@@ -250,6 +254,7 @@ class HtmlParser extends StatelessWidget {
250254
final render = customRender[tree.name]!.call(
251255
newContext,
252256
ContainerSpan(
257+
key: AnchorKey.of(key, tree),
253258
newContext: newContext,
254259
style: tree.style,
255260
shrinkWrap: context.parser.shrinkWrap,
@@ -262,6 +267,7 @@ class HtmlParser extends StatelessWidget {
262267
? render
263268
: WidgetSpan(
264269
child: ContainerSpan(
270+
key: AnchorKey.of(key, tree),
265271
newContext: newContext,
266272
style: tree.style,
267273
shrinkWrap: context.parser.shrinkWrap,
@@ -275,6 +281,7 @@ class HtmlParser extends StatelessWidget {
275281
if (tree.style.display == Display.BLOCK) {
276282
return WidgetSpan(
277283
child: ContainerSpan(
284+
key: AnchorKey.of(key, tree),
278285
newContext: newContext,
279286
style: tree.style,
280287
shrinkWrap: context.parser.shrinkWrap,
@@ -293,6 +300,7 @@ class HtmlParser extends StatelessWidget {
293300

294301
return WidgetSpan(
295302
child: ContainerSpan(
303+
key: AnchorKey.of(key, tree),
296304
newContext: newContext,
297305
style: tree.style,
298306
shrinkWrap: context.parser.shrinkWrap,
@@ -357,18 +365,23 @@ class HtmlParser extends StatelessWidget {
357365
: childStyle.merge(childSpan.style)),
358366
semanticsLabel: childSpan.semanticsLabel,
359367
recognizer: TapGestureRecognizer()
360-
..onTap = () => onLinkTap?.call(tree.href, context, tree.attributes, tree.element),
368+
..onTap =
369+
_onAnchorTap != null ? () => _onAnchorTap!(tree.href, context, tree.attributes, tree.element) : null,
361370
);
362371
} else {
363372
return WidgetSpan(
364373
child: RawGestureDetector(
374+
key: AnchorKey.of(key, tree),
365375
gestures: {
366376
MultipleTapGestureRecognizer:
367377
GestureRecognizerFactoryWithHandlers<
368378
MultipleTapGestureRecognizer>(
369379
() => MultipleTapGestureRecognizer(),
370380
(instance) {
371-
instance..onTap = () => onLinkTap?.call(tree.href, context, tree.attributes, tree.element);
381+
instance
382+
..onTap = _onAnchorTap != null
383+
? () => _onAnchorTap!(tree.href, context, tree.attributes, tree.element)
384+
: null;
372385
},
373386
),
374387
},
@@ -406,6 +419,7 @@ class HtmlParser extends StatelessWidget {
406419
//Requires special layout features not available in the TextStyle API.
407420
return WidgetSpan(
408421
child: Transform.translate(
422+
key: AnchorKey.of(key, tree),
409423
offset: Offset(0, verticalOffset),
410424
child: StyledText(
411425
textSpan: TextSpan(
@@ -424,11 +438,23 @@ class HtmlParser extends StatelessWidget {
424438
return TextSpan(
425439
style: newContext.style.generateTextStyle(),
426440
children:
427-
tree.children.map((tree) => parseTree(newContext, tree)).toList(),
441+
tree.children.map((tree) => parseTree(newContext, tree)).toList(),
428442
);
429443
}
430444
}
431445

446+
static OnTap _handleAnchorTap(Key key, OnTap? onLinkTap) =>
447+
(String? url, RenderContext context, Map<String, String> attributes, dom.Element? element) {
448+
if (url?.startsWith("#") == true) {
449+
final anchorContext = AnchorKey.forId(key, url!.substring(1))?.currentContext;
450+
if (anchorContext != null) {
451+
Scrollable.ensureVisible(anchorContext);
452+
}
453+
return;
454+
}
455+
onLinkTap?.call(url, context, attributes, element);
456+
};
457+
432458
/// [processWhitespace] removes unnecessary whitespace from the StyledElement tree.
433459
///
434460
/// The criteria for determining which whitespace is replaceable is outlined
@@ -738,19 +764,21 @@ class RenderContext {
738764
/// A [ContainerSpan] can have a border, background color, height, width, padding, and margin
739765
/// and can represent either an INLINE or BLOCK-level element.
740766
class ContainerSpan extends StatelessWidget {
767+
final AnchorKey? key;
741768
final Widget? child;
742769
final List<InlineSpan>? children;
743770
final Style style;
744771
final RenderContext newContext;
745772
final bool shrinkWrap;
746773

747774
ContainerSpan({
775+
this.key,
748776
this.child,
749777
this.children,
750778
required this.style,
751779
required this.newContext,
752780
this.shrinkWrap = false,
753-
});
781+
}): super(key: key);
754782

755783
@override
756784
Widget build(BuildContext _) {
@@ -782,13 +810,15 @@ class StyledText extends StatelessWidget {
782810
final Style style;
783811
final double textScaleFactor;
784812
final RenderContext renderContext;
813+
final AnchorKey? key;
785814

786815
const StyledText({
787816
required this.textSpan,
788817
required this.style,
789818
this.textScaleFactor = 1.0,
790819
required this.renderContext,
791-
});
820+
this.key,
821+
}) : super(key: key);
792822

793823
@override
794824
Widget build(BuildContext context) {

lib/src/anchor.dart

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import 'package:flutter/foundation.dart';
2+
import 'package:flutter/widgets.dart';
3+
import 'package:flutter_html/src/styled_element.dart';
4+
5+
class AnchorKey extends GlobalKey {
6+
final Key parentKey;
7+
final String id;
8+
9+
const AnchorKey._(this.parentKey, this.id) : super.constructor();
10+
11+
static AnchorKey? of(Key? parentKey, StyledElement? id) {
12+
return forId(parentKey, id?.elementId);
13+
}
14+
15+
static AnchorKey? forId(Key? parentKey, String? id) {
16+
if (parentKey == null || id == null || id.isEmpty || id == "[[No ID]]") {
17+
return null;
18+
}
19+
return AnchorKey._(parentKey, id);
20+
}
21+
22+
@override
23+
bool operator ==(Object other) =>
24+
identical(this, other) ||
25+
other is AnchorKey && runtimeType == other.runtimeType && parentKey == other.parentKey && id == other.id;
26+
27+
@override
28+
int get hashCode => parentKey.hashCode ^ id.hashCode;
29+
30+
@override
31+
String toString() {
32+
return 'AnchorKey{parentKey: $parentKey, id: #$id}';
33+
}
34+
}

lib/src/interactable_element.dart

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ class InteractableElement extends StyledElement {
1313
required Style style,
1414
required this.href,
1515
required dom.Node node,
16-
}) : super(name: name, children: children, style: style, node: node as dom.Element?);
16+
required String elementId,
17+
}) : super(name: name, children: children, style: style, node: node as dom.Element?, elementId: elementId);
1718
}
1819

1920
/// A [Gesture] indicates the type of interaction by a user.
@@ -34,6 +35,7 @@ InteractableElement parseInteractableElement(
3435
textDecoration: TextDecoration.underline,
3536
),
3637
node: element,
38+
elementId: element.id
3739
);
3840
/// will never be called, just to suppress missing return warning
3941
default:
@@ -43,6 +45,7 @@ InteractableElement parseInteractableElement(
4345
node: element,
4446
href: '',
4547
style: Style(),
48+
elementId: "[[No ID]]"
4649
);
4750
}
4851
}

lib/src/layout_element.dart

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import 'dart:math';
22

33
import 'package:flutter/material.dart';
44
import 'package:flutter_html/html_parser.dart';
5+
import 'package:flutter_html/src/anchor.dart';
56
import 'package:flutter_html/src/html_elements.dart';
67
import 'package:flutter_html/src/styled_element.dart';
78
import 'package:flutter_html/style.dart';
@@ -14,8 +15,9 @@ abstract class LayoutElement extends StyledElement {
1415
LayoutElement({
1516
String name = "[[No Name]]",
1617
required List<StyledElement> children,
18+
String? elementId,
1719
dom.Element? node,
18-
}) : super(name: name, children: children, style: Style(), node: node);
20+
}) : super(name: name, children: children, style: Style(), node: node, elementId: elementId ?? "[[No ID]]");
1921

2022
Widget? toWidget(RenderContext context);
2123
}
@@ -25,11 +27,12 @@ class TableLayoutElement extends LayoutElement {
2527
required String name,
2628
required List<StyledElement> children,
2729
required dom.Element node,
28-
}) : super(name: name, children: children, node: node);
30+
}) : super(name: name, children: children, node: node, elementId: node.id);
2931

3032
@override
3133
Widget toWidget(RenderContext context) {
3234
return Container(
35+
key: AnchorKey.of(context.parser.key, this),
3336
margin: style.margin,
3437
padding: style.padding,
3538
decoration: BoxDecoration(
@@ -263,7 +266,7 @@ class DetailsContentElement extends LayoutElement {
263266
required List<StyledElement> children,
264267
required dom.Element node,
265268
required this.elementList,
266-
}) : super(name: name, node: node, children: children);
269+
}) : super(name: name, node: node, children: children, elementId: node.id);
267270

268271
@override
269272
Widget toWidget(RenderContext context) {
@@ -279,6 +282,7 @@ class DetailsContentElement extends LayoutElement {
279282
}
280283
InlineSpan? firstChild = childrenList.isNotEmpty == true ? childrenList.first : null;
281284
return ExpansionTile(
285+
key: AnchorKey.of(context.parser.key, this),
282286
expandedAlignment: Alignment.centerLeft,
283287
title: elementList.isNotEmpty == true && elementList.first.localName == "summary" ? StyledText(
284288
textSpan: TextSpan(

0 commit comments

Comments
 (0)