Skip to content

Instantly share code, notes, and snippets.

@slightfoot
Last active July 1, 2023 15:57
Show Gist options
  • Save slightfoot/a666b38a8f1072b940c3024d464f8040 to your computer and use it in GitHub Desktop.
Save slightfoot/a666b38a8f1072b940c3024d464f8040 to your computer and use it in GitHub Desktop.
Static Html View Widget - by Simon Lightfoot - 12/11/2019
// MIT License
//
// Copyright (c) 2020 Simon Lightfoot
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
//
import 'package:async/async.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:html/dom.dart' as dom;
import 'package:html/dom_parsing.dart' as dom_parsing;
import 'package:html/parser.dart' show parse;
typedef OnLinkTapped = void Function(Uri link);
class StaticHtmlView extends StatefulWidget {
const StaticHtmlView({
Key key,
@required this.html,
this.baseUrl,
this.baseStyle,
this.onLinkTapped,
this.padding = EdgeInsets.zero,
}) : assert(html != null),
assert(padding != null),
super(key: key);
final String html;
final Uri baseUrl;
final TextStyle baseStyle;
final OnLinkTapped onLinkTapped;
final EdgeInsets padding;
@override
_StaticHtmlViewState createState() => _StaticHtmlViewState();
}
class _StaticHtmlViewState extends State<StaticHtmlView> {
ResultFuture<Widget> _widgetsFuture;
LocalHistoryEntry _historyEntry;
@override
void didChangeDependencies() {
super.didChangeDependencies();
if(_widgetsFuture == null){
_loadHtml();
}
}
@override
void didUpdateWidget(StaticHtmlView oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.html != widget.html) {
_loadHtml();
}
}
@override
void reassemble() {
super.reassemble();
_loadHtml();
}
void _loadHtml() {
final baseStyle = widget.baseStyle ?? DefaultTextStyle.of(context).style;
_widgetsFuture = ResultFuture<Widget>(_parseHtml(widget.html, baseStyle));
_widgetsFuture.whenComplete(() => setState(() {}));
}
@override
Widget build(BuildContext context) {
if (!_widgetsFuture.isComplete) {
return const Center(child: CircularProgressIndicator());
} else if (_widgetsFuture.result.isError) {
return ErrorWidget(_widgetsFuture.result.asError.error);
} else {
return _widgetsFuture.result.asValue.value;
}
}
void _onLinkTapped(Uri link) => widget.onLinkTapped?.call(link);
void _actionLinkTap(String href) {
// local anchor
if (href[0] == '#') {
final anchorKey = Key('tag-${href.substring(1)}');
Element found;
void visitElement(Element el) {
if (el.widget.key == anchorKey) {
found = el;
return;
}
el.visitChildren(visitElement);
}
context.visitChildElements(visitElement);
if (found != null) {
_scrollToAnchor(found);
}
} else {
final resolved = widget.baseUrl?.resolve(href) ?? Uri.parse(href);
_onLinkTapped(resolved);
}
}
void _scrollToAnchor(Element anchor) {
final scrollable = Scrollable.of(context);
final box = anchor.findRenderObject() as RenderBox;
final offset = box.localToGlobal(
Offset.zero,
ancestor: scrollable.context.findRenderObject(),
);
scrollable.position.animateTo(
scrollable.position.pixels + offset.dy,
duration: const Duration(milliseconds: 350),
curve: Curves.easeOutExpo,
);
if (_historyEntry == null) {
_historyEntry = LocalHistoryEntry(onRemove: () {
scrollable.position.animateTo(
0.0,
duration: const Duration(milliseconds: 350),
curve: Curves.easeOutExpo,
);
_historyEntry = null;
});
ModalRoute.of(context).addLocalHistoryEntry(_historyEntry);
}
}
Future<Widget> _parseHtml(String html, TextStyle baseStyle) async {
dom.Document document = parse(html);
return Padding(
padding: widget.padding,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: WidgetTreeVisitor(_actionLinkTap, baseStyle).parse(document),
),
);
}
}
class WidgetTreeVisitor extends dom_parsing.TreeVisitor {
WidgetTreeVisitor(this.onLinkTapped, TextStyle baseStyle) {
_current = TextSpan(
children: [],
style: baseStyle,
);
}
final ValueChanged<String> onLinkTapped;
final _children = <Widget>[];
TextSpan _current;
int index = 0;
List<Widget> get result => _children;
List<Widget> parse(dom.Document document) {
_children.clear();
visit(document);
return _children;
}
@override
void visitElement(dom.Element node) {
// Save old scope
TextSpan old = _current;
EdgeInsets _paragraphPadding;
// parse scope parameters
final style = _parseStyle(node);
TextAlign textAlign = TextAlign.left;
if (style.containsKey('text-align')) {
switch (style['text-align']) {
case 'center':
textAlign = TextAlign.center;
break;
case 'right':
textAlign = TextAlign.right;
break;
}
}
var textStyle = TextStyle(fontSize: old?.style?.fontSize);
if (_headingTags.contains(node.localName)) {
final _sizes = [1.8, 1.5, 1.3, 1.0, 0.8];
textStyle = textStyle.copyWith(
fontSize: textStyle.fontSize * _sizes[_headingTags.indexOf(node.localName)],
fontWeight: FontWeight.w600,
fontFamily: AppTheme.fontFamilyHeading,
);
_paragraphPadding = const EdgeInsets.only(top: 4.0, bottom: 8.0);
} else if (node.localName == 'strong' || node.localName == 'b') {
textStyle = textStyle.copyWith(fontWeight: FontWeight.w500);
} else if (node.localName == 'em' || node.localName == 'i') {
textStyle = textStyle.copyWith(fontStyle: FontStyle.italic);
} else if (node.localName == 'p') {
_paragraphPadding = const EdgeInsets.only(bottom: 12.0);
}
final id = node.attributes['id'];
if (id != null) {
_children.add(SizedBox(key: Key('tag-$id')));
}
// create new scope
_current = TextSpan(
children: [],
style: textStyle,
);
Widget table;
if (node.localName == 'table') {
final rows = <TableRow>[];
List<TableCell> row;
void tableVisitor(dom.Element element) {
if (element.localName == 'tr') {
row = <TableCell>[];
} else if (element.localName == 'td' || element.localName == 'th') {
var style = textStyle;
if (element.localName == 'th') {
style = textStyle.copyWith(fontWeight: FontWeight.w500);
}
_current = TextSpan(children: [], style: style);
super.visitElement(element);
row.add(TableCell(
child: Padding(
padding: const EdgeInsets.all(4.0),
child: Text.rich(
_current,
textAlign: textAlign,
),
),
));
_current = null;
}
for (final child in element.children) {
tableVisitor(child);
}
if (element.localName == 'tr') {
if (row.isNotEmpty) {
rows.add(TableRow(children: row));
}
}
}
for (final child in node.children) {
tableVisitor(child);
}
table = Table(
defaultVerticalAlignment: TableCellVerticalAlignment.top,
border: TableBorder.all(color: Colors.grey),
children: rows,
);
} else {
// visit scoped children
super.visitElement(node);
}
if (node.localName == 'table') {
_current = TextSpan(
children: <InlineSpan>[
WidgetSpan(child: table),
],
);
}
// link is special because recognizer only works on [text]
else if (node.localName == 'a') {
final href = node.attributes['href'];
if (href != null) {
_current = TextSpan(
text: _currentText(),
style: AppTheme.articleLinkStyle,
recognizer: TapGestureRecognizer()..onTap = () => onLinkTapped(href),
);
}
} else if (node.localName == 'ul' || node.localName == 'ol') {
final unordered = node.localName == 'ul';
final children = <InlineSpan>[
for (int index = 0; index < _current.children.length; index++)
WidgetSpan(
child: Container(
width: double.infinity,
padding: const EdgeInsets.only(left: 12.0, bottom: 8.0),
child: Row(
textBaseline: TextBaseline.alphabetic,
crossAxisAlignment: CrossAxisAlignment.baseline,
children: <Widget>[
if (unordered) const Icon(Icons.fiber_manual_record, size: 9.0) else Text('${index + 1}. '),
const SizedBox(width: 8.0),
Expanded(
child: Text.rich(
_current.children[index],
style: textStyle.copyWith(
height: 1.2,
),
),
),
],
),
),
),
];
_current = TextSpan(children: children);
textAlign = TextAlign.left;
}
if (isBlock(node)) {
if (_hasCurrentContent()) {
// add scope to final tree
Widget child = Text.rich(
_current,
textAlign: textAlign,
);
if (_paragraphPadding != null) {
child = Padding(
padding: _paragraphPadding,
child: child,
);
}
_children.add(child);
}
} else if (_current.text != null || _current.children.isNotEmpty) {
old.children.add(_current);
}
// restore original scope
_current = old;
}
@override
void visitText(dom.Text node) {
if (node.data.trim().isNotEmpty) {
final text = List<int>.from(node.data.codeUnits);
for (int i = text.length - 1; i >= 0; i--) {
if (text[i] == 10 || text[i] == 13) {
text.removeAt(i);
}
}
_current.children.add(TextSpan(text: String.fromCharCodes(text)));
}
}
bool _hasCurrentContent() {
bool _visitSpan(InlineSpan span) {
if (span is TextSpan) {
if (span.text != null && span.text.trim().isNotEmpty) {
return true;
}
if (span.children != null) {
for (final child in span.children) {
if (_visitSpan(child)) {
return true;
}
}
}
} else if (span is WidgetSpan) {
return true;
}
return false;
}
return _visitSpan(_current);
}
String _currentText() {
final text = StringBuffer();
void _visitSpan(TextSpan span) {
if (span.text != null) {
text.write(span.text);
}
if (span.children != null) {
for (final child in span.children) {
_visitSpan(child);
}
}
}
_visitSpan(_current);
return text.toString();
}
static const _headingTags = ['h1', 'h2', 'h3', 'h4', 'h5'];
static const _blockTags = ['div', 'h1', 'h2', 'h3', 'h4', 'h5', 'ul', 'ol', 'p'];
bool isBlock(dom.Element node) => _blockTags.contains(node.localName);
Map<String, String> _parseStyle(dom.Node node) {
final style = node.attributes['style'];
if (style == null) {
return const {};
}
final styles = <String, String>{};
for (final attr in style.split(';')) {
if (attr.trim().isEmpty) continue;
if (!attr.contains(':')) continue;
final inner = attr.split(':');
styles[inner[0].trim()] = inner[1].trim();
}
return styles;
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment