From a8f09fd73212ce90cccfe9a0289341271805ba93 Mon Sep 17 00:00:00 2001 From: Jeff Mikels Date: Tue, 6 Nov 2018 11:43:12 -0500 Subject: [PATCH 1/5] Added support for rendering as RichText Widgets --- lib/flutter_html.dart | 11 +- lib/html_parser.dart | 650 +++++++++++++++++++++++++++++++++++++++++- 2 files changed, 658 insertions(+), 3 deletions(-) diff --git a/lib/flutter_html.dart b/lib/flutter_html.dart index 05f344c437..a04b329d83 100644 --- a/lib/flutter_html.dart +++ b/lib/flutter_html.dart @@ -13,6 +13,7 @@ class Html extends StatelessWidget { this.onLinkTap, this.renderNewlines = false, this.customRender, + this.useRichText = false, }) : super(key: key); final String data; @@ -21,6 +22,7 @@ class Html extends StatelessWidget { final TextStyle defaultTextStyle; final OnLinkTap onLinkTap; final bool renderNewlines; + final bool useRichText; /// Either return a custom widget for specific node types or return null to /// fallback to the default rendering. @@ -38,7 +40,14 @@ class Html extends StatelessWidget { style: defaultTextStyle, child: Wrap( alignment: WrapAlignment.start, - children: HtmlParser( + children: (useRichText) + ? HtmlRichTextParser( + width: width, + onLinkTap: onLinkTap, + renderNewlines: renderNewlines, + customRender: customRender, + ).parse(data) + : HtmlOldParser( width: width, onLinkTap: onLinkTap, renderNewlines: renderNewlines, diff --git a/lib/html_parser.dart b/lib/html_parser.dart index 72a802c881..bfb66258f9 100644 --- a/lib/html_parser.dart +++ b/lib/html_parser.dart @@ -1,12 +1,658 @@ import 'package:flutter/material.dart'; +import 'package:flutter/gestures.dart'; import 'package:html/parser.dart' as parser; import 'package:html/dom.dart' as dom; typedef CustomRender = Widget Function(dom.Node node, List children); typedef OnLinkTap = void Function(String url); -class HtmlParser { - HtmlParser({ +class LinkTextSpan extends TextSpan { + // Beware! + // + // This class is only safe because the TapGestureRecognizer is not + // given a deadline and therefore never allocates any resources. + // + // In any other situation -- setting a deadline, using any of the less trivial + // recognizers, etc -- you would have to manage the gesture recognizer's + // lifetime and call dispose() when the TextSpan was no longer being rendered. + // + // Since TextSpan itself is @immutable, this means that you would have to + // manage the recognizer from outside the TextSpan, e.g. in the State of a + // stateful widget that then hands the recognizer to the TextSpan. + final String url; + + LinkTextSpan({ TextStyle style, this.url, String text, OnLinkTap onLinkTap, List children }) : super( + style: style, + text: text, + children: children ?? [], + recognizer: TapGestureRecognizer()..onTap = () { + onLinkTap(url); + } + ); +} + +class LinkBlock extends Container { + // final String url; + // final EdgeInsets padding; + // final EdgeInsets margin; + // final OnLinkTap onLinkTap; + final List children; + + LinkBlock({ String url, EdgeInsets padding, EdgeInsets margin, OnLinkTap onLinkTap, List this.children }):super( + padding: padding, + margin:margin, + child: GestureDetector( + onTap: (){ + onLinkTap(url); + }, + child:Column( + children: children, + ) + ) + ); +} + +class BlockText extends StatelessWidget { + + final RichText child; + final EdgeInsets padding; + final EdgeInsets margin; + final String leadingChar; + final Decoration decoration; + + BlockText({@required this.child, this.padding, this.margin, this.leadingChar = '',this.decoration}); + + @override + Widget build(BuildContext context) { + return Container( + width:double.infinity, + padding: this.padding, + margin: this.margin, + decoration:this.decoration, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + leadingChar.isNotEmpty ? Text(leadingChar) : Container(), + Expanded(child:child), + ], + ) + ); + } +} + +class ParseContext { + List rootWidgetList; // the widgetList accumulator + dynamic parentElement; // the parent spans accumulator + int indentLevel = 0; + int listCount = 0; + String listChar = '•'; + String blockType; // blockType can be 'p', 'div', 'ul', 'ol', 'blockquote' + bool condenseWhitespace = true; + bool spansOnly = false; + TextStyle childStyle; + + ParseContext({ + this.rootWidgetList, + this.parentElement, + this.indentLevel = 0, + this.listCount = 0, + this.listChar = '•', + this.blockType, + this.condenseWhitespace = true, + this.spansOnly = false, + this.childStyle}) { + childStyle = childStyle ?? TextStyle(); + } + + ParseContext.fromContext(ParseContext parseContext){ + rootWidgetList = parseContext.rootWidgetList; + parentElement = parseContext.parentElement; + indentLevel = parseContext.indentLevel; + listCount = parseContext.listCount; + listChar = parseContext.listChar; + blockType = parseContext.blockType; + condenseWhitespace = parseContext.condenseWhitespace; + spansOnly = parseContext.spansOnly; + childStyle = parseContext.childStyle ?? TextStyle(); + } +} + +class HtmlRichTextParser { + HtmlRichTextParser({ + @required this.width, + this.onLinkTap, + this.renderNewlines = false, + this.customRender, + this.context, + }); + + final double indentSize = 10.0; + + final double width; + final onLinkTap; + final bool renderNewlines; + final CustomRender customRender; + final BuildContext context; + + // style elements set a default style + // for all child nodes + // treat ol, ul, and blockquote like style elements also + static const _supportedStyleElements = [ + "b","i","em","strong","code","u","small","abbr","acronym", "ol", "ul", "blockquote" + ]; + + // specialty elements require unique handling + // eg. the "a" tag can contain a block of text or an image + // sometimes "a" will be rendered with a textspan and recognizer + // sometimes "a" will be rendered with a clickable Block + static const _supportedSpecialtyElements = [ + "a", + "br", + "table", + "tbody", + "td", + "tfoot", + "th", + "thead", + "tr", + ]; + + // block elements are always rendered with a new + // block-level widget, if a block level element + // is found inside another block level element, + // we simply treat it as a new block level element + static const _supportedBlockElements = [ + "article" + "body", + "center", + "dd", + "dfn", + "div", + "dl", + "dt", + "figcaption", + "figure", + "footer", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "header", + "hr", + "img", + "li", + "main", + "p", + "pre", + "section", + ]; + + static get _supportedElements => List() + ..addAll(_supportedStyleElements) + ..addAll(_supportedSpecialtyElements) + ..addAll(_supportedBlockElements); + + // this function is called recursively for each child + // however, the first time it is called, we make sure + // to ignore the node itself, so we only pay attention + // to the children + bool _hasBlockChild(dom.Node node, {bool ignoreSelf = true}) { + bool retval = false; + if (node is dom.Element) { + if (_supportedBlockElements.contains(node.localName) && !ignoreSelf) return true; + node.nodes.forEach((dom.Node node) { + if (_hasBlockChild(node, ignoreSelf: false)) retval = true; + }); + } + return retval; + } + + // Parses an html string and returns a list of RichText widgets that represent the body of your html document. + List parse(String data) { + + if (renderNewlines) { + data = data.replaceAll("\n", "
"); + } + dom.Document document = parser.parse(data); + dom.Node body = document.body; + + List widgetList = new List(); + ParseContext parseContext = ParseContext( + rootWidgetList: widgetList, + childStyle: DefaultTextStyle.of(context).style, + ); + + // ignore the top level "body" + body.nodes.forEach((dom.Node node)=>_parseNode(node, parseContext)); + // _parseNode(body, parseContext); + + // eliminate empty widgets + List retval = []; + widgetList.forEach((dynamic w){ + if (w is BlockText) { + if (w.child.text == null) return; + if (w.child.text.text.isEmpty && w.child.text.children.isEmpty) return; + } else if (w is LinkBlock) { + if (w.children.isEmpty) return; + } else if (w is LinkTextSpan) { + if (w.text.isEmpty && w.children.isEmpty) return; + } + retval.add(w); + }); + return retval; + } + + // THE WORKHORSE FUNCTION!! + // call the function with the current node and a ParseContext + // the ParseContext is used to do a number of things + // first, since we call this function recursively, the parseContext holds references to + // all the data that is relevant to a particular iteration and its child iterations + // it holds information about whether to indent the text, whether we are in a list, etc. + // + // secondly, it holds the 'global' widgetList that accumulates all the block-level widgets + // + // thirdly, it holds a reference to the most recent "parent" so that this iteration of the + // function can add child nodes to the parent if it should + // + // each iteration creates a new parseContext as a copy of the previous one if it needs to + void _parseNode(dom.Node node, ParseContext parseContext) { + + // TEXT ONLY NODES + // a text only node is a child of a tag with no inner html + if (node is dom.Text) { + + // WHITESPACE CONSIDERATIONS --- + // truly empty nodes, should just be ignored + if (node.text.trim() == "" && node.text.indexOf(" ") == -1) { + return; + } + + // empty strings of whitespace might be significant or not, condense it by default + if (node.text.trim() == "" && node.text.indexOf(" ") != -1 && parseContext.condenseWhitespace) { + node.text = " "; + } + + // we might want to preserve internal whitespace + String finalText = parseContext.condenseWhitespace ? condenseHtmlWhitespace(node.text) : node.text; + + // if this is part of a string of spans, we will preserve leading and trailing whitespace + if (!(parseContext.parentElement is TextSpan || parseContext.parentElement is LinkTextSpan)) + finalText = finalText.trim(); + + + // NOW WE HAVE OUR TRULY FINAL TEXT + debugPrint("Plain Text Node: '$finalText'"); + + // create a span by default + TextSpan span = TextSpan(text:finalText, children:[], style:parseContext.childStyle); + + // in this class, a ParentElement must be a BlockText, LinkTextSpan, Row, Column, TextSpan + + // if there is no parentElement, contain the span in a BlockText + if (parseContext.parentElement == null){ + parseContext.parentElement = span; + parseContext.rootWidgetList.add(BlockText(child:RichText(text: span))); + + // if the parent is a LinkTextSpan, keep the main attributes of that span going. + } else if (parseContext.parentElement is LinkTextSpan){ + // add this node to the parent as another LinkTextSpan + parseContext.parentElement.children.add( + LinkTextSpan( + style: parseContext.parentElement.style.merge(parseContext.childStyle), + url: parseContext.parentElement.url, + text: finalText, + onLinkTap: onLinkTap, + ) + ); + + // if the parent is a normal span, just add this to that list + } else { + parseContext.parentElement.children.add(span); + } + return; + } + // OTHER ELEMENT NODES + else if (node is dom.Element) { + assert(() { + debugPrint("Found ${node.localName}"); + debugPrint(node.outerHtml); + return true; + }()); + + if (! _supportedElements.contains(node.localName)) { + return; + } + + // make a copy of the current context so that we can modify + // pieces of it for the next iteration of this function + ParseContext nextContext = new ParseContext.fromContext(parseContext); + + // handle style elements + if ( _supportedStyleElements.contains(node.localName)) { + TextStyle childStyle = parseContext.childStyle ?? TextStyle(); + + switch (node.localName) { + //"b","i","em","strong","code","u","small","abbr","acronym" + case "b": + case "strong": + childStyle = childStyle.merge(TextStyle(fontWeight: FontWeight.bold)); + break; + case "i": + case "em": + childStyle = childStyle.merge(TextStyle(fontStyle: FontStyle.italic)); + break; + case "code": + childStyle = childStyle.merge(TextStyle(fontFamily: 'monospace')); + break; + case "u": + childStyle = childStyle.merge(TextStyle(decoration: TextDecoration.underline)); + break; + case "abbr": + case "acronym": + childStyle = childStyle.merge(TextStyle( + decoration: TextDecoration.underline, + decorationStyle: TextDecorationStyle.dotted, + )); + break; + case "small": + childStyle = childStyle.merge(TextStyle(fontSize: 12.0)); + break; + case "ol": + nextContext.indentLevel += 1; + nextContext.listChar = '#'; + nextContext.listCount = 0; + nextContext.blockType = 'ol'; + break; + case "ul": + nextContext.indentLevel += 1; + nextContext.listChar = '•'; + nextContext.listCount = 0; + nextContext.blockType = 'ul'; + break; + case "blockquote": + nextContext.indentLevel += 1; + nextContext.blockType = 'blockquote'; + break; + } + nextContext.childStyle = childStyle; + } + + // handle specialty elements + else if ( _supportedSpecialtyElements.contains(node.localName)) { + // should support "a","br","table","tbody","thead","tfoot","th","tr","td" + + switch (node.localName) { + case "a": + // if this item has block children, we create + // a container and gesture recognizer for the entire + // element, otherwise, we create a LinkTextSpan + String url = node.attributes['href'] ?? null; + + if (_hasBlockChild(node)) { + LinkBlock linkContainer = LinkBlock( + url: url, + margin: EdgeInsets.only(left:parseContext.indentLevel * indentSize), + onLinkTap: onLinkTap, + children: [], + ); + nextContext.parentElement = linkContainer; + nextContext.rootWidgetList.add(linkContainer); + } + else { + TextStyle linkStyle = parseContext.childStyle.merge( + TextStyle( + decoration: TextDecoration.underline, + color: Colors.blueAccent, + decorationColor: Colors.blueAccent, + ) + ); + LinkTextSpan span = LinkTextSpan( + style: linkStyle, + url: url, + onLinkTap: onLinkTap, + ); + if (parseContext.parentElement is TextSpan){ + nextContext.parentElement.children.add(span); + } + else { + // start a new block element for this link and its text + BlockText blockElement = BlockText( + margin: EdgeInsets.only(left:parseContext.indentLevel * indentSize, top:10.0), + child: RichText(text: span), + ); + parseContext.rootWidgetList.add(blockElement); + } + nextContext.childStyle = linkStyle; + nextContext.parentElement = span; + } + break; + + case "br": + if (parseContext.parentElement != null && parseContext.parentElement is TextSpan) { + parseContext.parentElement.children.add(TextSpan(text:'\n', children: [])); + } + break; + + case "table": + case "tbody": + case "thead": + // new block, so clear out the parent element + parseContext.parentElement = null; + nextContext.parentElement = Column(crossAxisAlignment: CrossAxisAlignment.start,); + nextContext.rootWidgetList.add(nextContext.parentElement); + break; + + case "td": + case "th": + int colspan = 1; + if (node.attributes['colspan'] != null) { + colspan = int.tryParse(node.attributes['colspan']); + } + Expanded cell = Expanded( + flex: colspan, + child: Wrap(), + ); + nextContext.parentElement.children.add(cell); + nextContext.parentElement = cell.child; + break; + + case "tr": + Row row = Row(crossAxisAlignment: CrossAxisAlignment.center,); + nextContext.parentElement.children.add(row); + nextContext.parentElement = row; + break; + } + } + + // handle block elements + else if ( _supportedBlockElements.contains(node.localName)) { + + // block elements only show up at the "root" widget level + // so if we have a block element, reset the parentElement to null + parseContext.parentElement = null; + TextAlign textAlign = TextAlign.left; + + switch (node.localName) { + case "hr": + parseContext.rootWidgetList.add(Divider(height:1.0, color:Colors.black38)); + break; + case "img": + if (node.attributes['src'] != null) { + parseContext.rootWidgetList.add(Image.network(node.attributes['src'])); + } else if (node.attributes['alt'] != null) { + parseContext.rootWidgetList.add(BlockText( + margin:EdgeInsets.symmetric(horizontal: 0.0, vertical: 10.0), + padding:EdgeInsets.all(0.0), + child:RichText(text:TextSpan(text: node.attributes['alt'], children:[],)) + )); + } + break; + case "li": + String leadingChar = parseContext.listChar; + if (parseContext.blockType == 'ol') { + nextContext.listCount += 1; + leadingChar = nextContext.listCount.toString() + '.'; + } + BlockText blockText = BlockText( + margin:EdgeInsets.only(left: parseContext.indentLevel * indentSize, top:3.0), + child: RichText( + text:TextSpan( + text:'', + style:nextContext.childStyle, + children: [], + ), + ), + leadingChar: '$leadingChar ', + ); + parseContext.rootWidgetList.add(blockText); + nextContext.parentElement = blockText.child.text; + nextContext.spansOnly = true; + break; + + case "h1": + nextContext.childStyle = nextContext.childStyle.merge( + TextStyle(fontSize: 26.0, fontWeight: FontWeight.bold), + ); + continue myDefault; + case "h2": + nextContext.childStyle = nextContext.childStyle.merge( + TextStyle(fontSize: 24.0, fontWeight: FontWeight.bold), + ); + continue myDefault; + case "h3": + nextContext.childStyle = nextContext.childStyle.merge( + TextStyle(fontSize: 22.0, fontWeight: FontWeight.bold), + ); + continue myDefault; + case "h4": + nextContext.childStyle = nextContext.childStyle.merge( + TextStyle(fontSize: 20.0, fontWeight: FontWeight.w100), + ); + continue myDefault; + case "h5": + nextContext.childStyle = nextContext.childStyle.merge( + TextStyle(fontSize: 18.0, fontWeight: FontWeight.bold), + ); + continue myDefault; + case "h6": + nextContext.childStyle = nextContext.childStyle.merge( + TextStyle(fontSize: 18.0, fontWeight: FontWeight.w100), + ); + continue myDefault; + + case "pre": + nextContext.condenseWhitespace = false; + continue myDefault; + + case "center": + textAlign = TextAlign.center; + // no break here + continue myDefault; + + myDefault: + default: + Decoration decoration; + if (parseContext.blockType == 'blockquote') { + decoration = BoxDecoration( + border: Border(left: BorderSide(color:Colors.black38, width:2.0)), + ); + nextContext.childStyle = nextContext.childStyle.merge(TextStyle( + fontStyle: FontStyle.italic, + )); + } + BlockText blockText = BlockText( + margin:EdgeInsets.symmetric(vertical:8.0), + padding:EdgeInsets.only(left: parseContext.indentLevel * indentSize,), + decoration:decoration, + child: RichText( + textAlign: textAlign, + text:TextSpan( + text:'', + style: nextContext.childStyle, + children: [], + ), + ), + ); + parseContext.rootWidgetList.add(blockText); + nextContext.parentElement = blockText.child.text; + nextContext.spansOnly = true; + } + } + + node.nodes.forEach((dom.Node childNode){ + _parseNode(childNode, nextContext); + }); + } + } + + // List _parseNodeList({ + // @required List nodeList, + // @required List rootWidgetList, // the widgetList accumulator + // int parentIndex, // the parent spans list accumulator + // int indentLevel = 0, + // int listCount = 0, + // String listChar = '•', + // String blockType, // blockType can be 'p', 'div', 'ul', 'ol', 'blockquote' + // bool condenseWhitespace = true, + // }) { + // return nodeList.map((node) { + // return _parseNode( + // node: node, + // rootWidgetList: rootWidgetList, + // parentIndex: parentIndex, + // indentLevel: indentLevel, + // listCount: listCount, + // listChar: listChar, + // blockType: blockType, + // condenseWhitespace: condenseWhitespace, + // ); + // }).toList(); + // } + + Paint _getPaint(Color color) { + Paint paint = new Paint(); + paint.color = color; + return paint; + } + + String condenseHtmlWhitespace(String stringToTrim) { + stringToTrim = stringToTrim.replaceAll("\n", " "); + while (stringToTrim.indexOf(" ") != -1) { + stringToTrim = stringToTrim.replaceAll(" ", " "); + } + return stringToTrim; + } + + bool _isNotFirstBreakTag(dom.Node node) { + int index = node.parentNode.nodes.indexOf(node); + if (index == 0) { + if (node.parentNode == null) { + return false; + } + return _isNotFirstBreakTag(node.parentNode); + } else if (node.parentNode.nodes[index - 1] is dom.Element) { + if ((node.parentNode.nodes[index - 1] as dom.Element).localName == "br") { + return true; + } + return false; + } else if (node.parentNode.nodes[index - 1] is dom.Text) { + if ((node.parentNode.nodes[index - 1] as dom.Text).text.trim() == "") { + return _isNotFirstBreakTag(node.parentNode.nodes[index - 1]); + } else { + return false; + } + } + return false; + } +} + + + + +class HtmlOldParser { + HtmlOldParser({ @required this.width, this.onLinkTap, this.renderNewlines = false, From 8fcdd431348ee03eec31a916cfcd2ffd9d189daf Mon Sep 17 00:00:00 2001 From: Jeff Mikels Date: Tue, 6 Nov 2018 11:45:20 -0500 Subject: [PATCH 2/5] fixing the test to keep using the old parser --- test/html_parser_test.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/html_parser_test.dart b/test/html_parser_test.dart index bf09259e41..af06eeecc1 100644 --- a/test/html_parser_test.dart +++ b/test/html_parser_test.dart @@ -5,7 +5,7 @@ import 'package:flutter_html/flutter_html.dart'; void main() { test('Checks that `parse` does not throw an exception', () { - final elementList = HtmlParser(width: 42.0).parse("Bold Text"); + final elementList = HtmlOldParser(width: 42.0).parse("Bold Text"); expect(elementList, isNotNull); }); From 98b713e82ce37282fe9602d8cdf422de4dfc1dc2 Mon Sep 17 00:00:00 2001 From: Jeff Mikels Date: Tue, 6 Nov 2018 12:31:55 -0500 Subject: [PATCH 3/5] fixed ordered list numbering --- lib/flutter_html.dart | 32 +++++++++++++++----------------- lib/html_parser.dart | 40 ++++++++++++++++++++++++++++------------ 2 files changed, 43 insertions(+), 29 deletions(-) diff --git a/lib/flutter_html.dart b/lib/flutter_html.dart index a04b329d83..e7fdef9c15 100644 --- a/lib/flutter_html.dart +++ b/lib/flutter_html.dart @@ -9,7 +9,7 @@ class Html extends StatelessWidget { @required this.data, this.padding, this.backgroundColor, - this.defaultTextStyle = const TextStyle(color: Colors.black), + this.defaultTextStyle, this.onLinkTap, this.renderNewlines = false, this.customRender, @@ -37,22 +37,20 @@ class Html extends StatelessWidget { color: backgroundColor, width: width, child: DefaultTextStyle.merge( - style: defaultTextStyle, - child: Wrap( - alignment: WrapAlignment.start, - children: (useRichText) - ? HtmlRichTextParser( - width: width, - onLinkTap: onLinkTap, - renderNewlines: renderNewlines, - customRender: customRender, - ).parse(data) - : HtmlOldParser( - width: width, - onLinkTap: onLinkTap, - renderNewlines: renderNewlines, - customRender: customRender, - ).parse(data), + style: defaultTextStyle ?? DefaultTextStyle.of(context).style, + child: (useRichText) + ? HtmlRichTextParser( + width: width, + onLinkTap: onLinkTap, + renderNewlines: renderNewlines, + html: data, + ) + : HtmlOldParser( + width: width, + onLinkTap: onLinkTap, + renderNewlines: renderNewlines, + customRender: customRender, + html: data, ), ), ); diff --git a/lib/html_parser.dart b/lib/html_parser.dart index bfb66258f9..6a3bd6f052 100644 --- a/lib/html_parser.dart +++ b/lib/html_parser.dart @@ -117,13 +117,12 @@ class ParseContext { } } -class HtmlRichTextParser { +class HtmlRichTextParser extends StatelessWidget{ HtmlRichTextParser({ @required this.width, this.onLinkTap, this.renderNewlines = false, - this.customRender, - this.context, + this.html, }); final double indentSize = 10.0; @@ -131,8 +130,7 @@ class HtmlRichTextParser { final double width; final onLinkTap; final bool renderNewlines; - final CustomRender customRender; - final BuildContext context; + final String html; // style elements set a default style // for all child nodes @@ -210,7 +208,10 @@ class HtmlRichTextParser { } // Parses an html string and returns a list of RichText widgets that represent the body of your html document. - List parse(String data) { + + @override + Widget build(BuildContext context) { + String data = html; if (renderNewlines) { data = data.replaceAll("\n", "
"); @@ -229,7 +230,7 @@ class HtmlRichTextParser { // _parseNode(body, parseContext); // eliminate empty widgets - List retval = []; + List children = []; widgetList.forEach((dynamic w){ if (w is BlockText) { if (w.child.text == null) return; @@ -239,9 +240,12 @@ class HtmlRichTextParser { } else if (w is LinkTextSpan) { if (w.text.isEmpty && w.children.isEmpty) return; } - retval.add(w); + children.add(w); }); - return retval; + + return Column( + children: children, + ); } // THE WORKHORSE FUNCTION!! @@ -492,8 +496,10 @@ class HtmlRichTextParser { case "li": String leadingChar = parseContext.listChar; if (parseContext.blockType == 'ol') { - nextContext.listCount += 1; - leadingChar = nextContext.listCount.toString() + '.'; + // nextContext will handle nodes under this 'li' + // but we want to increment the count at this level + parseContext.listCount += 1; + leadingChar = parseContext.listCount.toString() + '.'; } BlockText blockText = BlockText( margin:EdgeInsets.only(left: parseContext.indentLevel * indentSize, top:3.0), @@ -651,18 +657,20 @@ class HtmlRichTextParser { -class HtmlOldParser { +class HtmlOldParser extends StatelessWidget { HtmlOldParser({ @required this.width, this.onLinkTap, this.renderNewlines = false, this.customRender, + this.html, }); final double width; final OnLinkTap onLinkTap; final bool renderNewlines; final CustomRender customRender; + final String html; static const _supportedElements = [ "a", @@ -739,6 +747,14 @@ class HtmlOldParser { "var", ]; + @override + Widget build(BuildContext context) { + return Wrap( + alignment: WrapAlignment.start, + children: parse(html), + ); + } + ///Parses an html string and returns a list of widgets that represent the body of your html document. List parse(String data) { List widgetList = new List(); From 7136a59f35ca3b3372a969ccb60f22370b003d9a Mon Sep 17 00:00:00 2001 From: Jeff Mikels Date: Sat, 19 Jan 2019 14:47:37 -0500 Subject: [PATCH 4/5] check for null before calling isEmpty --- lib/html_parser.dart | 312 +++++++++++++++++++++---------------- test/html_parser_test.dart | 8 +- 2 files changed, 182 insertions(+), 138 deletions(-) diff --git a/lib/html_parser.dart b/lib/html_parser.dart index 6a3bd6f052..706e434a77 100644 --- a/lib/html_parser.dart +++ b/lib/html_parser.dart @@ -21,14 +21,20 @@ class LinkTextSpan extends TextSpan { // stateful widget that then hands the recognizer to the TextSpan. final String url; - LinkTextSpan({ TextStyle style, this.url, String text, OnLinkTap onLinkTap, List children }) : super( - style: style, - text: text, - children: children ?? [], - recognizer: TapGestureRecognizer()..onTap = () { - onLinkTap(url); - } - ); + LinkTextSpan( + {TextStyle style, + this.url, + String text, + OnLinkTap onLinkTap, + List children}) + : super( + style: style, + text: text, + children: children ?? [], + recognizer: TapGestureRecognizer() + ..onTap = () { + onLinkTap(url); + }); } class LinkBlock extends Container { @@ -38,73 +44,80 @@ class LinkBlock extends Container { // final OnLinkTap onLinkTap; final List children; - LinkBlock({ String url, EdgeInsets padding, EdgeInsets margin, OnLinkTap onLinkTap, List this.children }):super( - padding: padding, - margin:margin, - child: GestureDetector( - onTap: (){ - onLinkTap(url); - }, - child:Column( - children: children, - ) - ) - ); + LinkBlock( + {String url, + EdgeInsets padding, + EdgeInsets margin, + OnLinkTap onLinkTap, + List this.children}) + : super( + padding: padding, + margin: margin, + child: GestureDetector( + onTap: () { + onLinkTap(url); + }, + child: Column( + children: children, + ))); } class BlockText extends StatelessWidget { - final RichText child; final EdgeInsets padding; final EdgeInsets margin; final String leadingChar; final Decoration decoration; - BlockText({@required this.child, this.padding, this.margin, this.leadingChar = '',this.decoration}); - + BlockText( + {@required this.child, + this.padding, + this.margin, + this.leadingChar = '', + this.decoration}); + @override Widget build(BuildContext context) { return Container( - width:double.infinity, - padding: this.padding, - margin: this.margin, - decoration:this.decoration, - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - leadingChar.isNotEmpty ? Text(leadingChar) : Container(), - Expanded(child:child), - ], - ) - ); + width: double.infinity, + padding: this.padding, + margin: this.margin, + decoration: this.decoration, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + leadingChar.isNotEmpty ? Text(leadingChar) : Container(), + Expanded(child: child), + ], + )); } } class ParseContext { - List rootWidgetList; // the widgetList accumulator - dynamic parentElement; // the parent spans accumulator + List rootWidgetList; // the widgetList accumulator + dynamic parentElement; // the parent spans accumulator int indentLevel = 0; int listCount = 0; String listChar = '•'; - String blockType; // blockType can be 'p', 'div', 'ul', 'ol', 'blockquote' + String blockType; // blockType can be 'p', 'div', 'ul', 'ol', 'blockquote' bool condenseWhitespace = true; bool spansOnly = false; TextStyle childStyle; - ParseContext({ - this.rootWidgetList, - this.parentElement, - this.indentLevel = 0, - this.listCount = 0, - this.listChar = '•', - this.blockType, - this.condenseWhitespace = true, - this.spansOnly = false, - this.childStyle}) { - childStyle = childStyle ?? TextStyle(); - } + ParseContext( + {this.rootWidgetList, + this.parentElement, + this.indentLevel = 0, + this.listCount = 0, + this.listChar = '•', + this.blockType, + this.condenseWhitespace = true, + this.spansOnly = false, + this.childStyle}) { + childStyle = childStyle ?? TextStyle(); + } - ParseContext.fromContext(ParseContext parseContext){ + ParseContext.fromContext(ParseContext parseContext) { rootWidgetList = parseContext.rootWidgetList; parentElement = parseContext.parentElement; indentLevel = parseContext.indentLevel; @@ -117,7 +130,7 @@ class ParseContext { } } -class HtmlRichTextParser extends StatelessWidget{ +class HtmlRichTextParser extends StatelessWidget { HtmlRichTextParser({ @required this.width, this.onLinkTap, @@ -136,7 +149,18 @@ class HtmlRichTextParser extends StatelessWidget{ // for all child nodes // treat ol, ul, and blockquote like style elements also static const _supportedStyleElements = [ - "b","i","em","strong","code","u","small","abbr","acronym", "ol", "ul", "blockquote" + "b", + "i", + "em", + "strong", + "code", + "u", + "small", + "abbr", + "acronym", + "ol", + "ul", + "blockquote" ]; // specialty elements require unique handling @@ -161,7 +185,7 @@ class HtmlRichTextParser extends StatelessWidget{ // we simply treat it as a new block level element static const _supportedBlockElements = [ "article" - "body", + "body", "center", "dd", "dfn", @@ -199,7 +223,8 @@ class HtmlRichTextParser extends StatelessWidget{ bool _hasBlockChild(dom.Node node, {bool ignoreSelf = true}) { bool retval = false; if (node is dom.Element) { - if (_supportedBlockElements.contains(node.localName) && !ignoreSelf) return true; + if (_supportedBlockElements.contains(node.localName) && !ignoreSelf) + return true; node.nodes.forEach((dom.Node node) { if (_hasBlockChild(node, ignoreSelf: false)) retval = true; }); @@ -218,7 +243,7 @@ class HtmlRichTextParser extends StatelessWidget{ } dom.Document document = parser.parse(data); dom.Node body = document.body; - + List widgetList = new List(); ParseContext parseContext = ParseContext( rootWidgetList: widgetList, @@ -226,15 +251,17 @@ class HtmlRichTextParser extends StatelessWidget{ ); // ignore the top level "body" - body.nodes.forEach((dom.Node node)=>_parseNode(node, parseContext)); + body.nodes.forEach((dom.Node node) => _parseNode(node, parseContext)); // _parseNode(body, parseContext); - // eliminate empty widgets + // filter out empty widgets List children = []; - widgetList.forEach((dynamic w){ + widgetList.forEach((dynamic w) { if (w is BlockText) { if (w.child.text == null) return; - if (w.child.text.text.isEmpty && w.child.text.children.isEmpty) return; + if ((w.child.text.text == null || w.child.text.text.isEmpty) && + (w.child.text.children == null || w.child.text.children.isEmpty)) + return; } else if (w is LinkBlock) { if (w.children.isEmpty) return; } else if (w is LinkTextSpan) { @@ -262,11 +289,9 @@ class HtmlRichTextParser extends StatelessWidget{ // // each iteration creates a new parseContext as a copy of the previous one if it needs to void _parseNode(dom.Node node, ParseContext parseContext) { - // TEXT ONLY NODES // a text only node is a child of a tag with no inner html if (node is dom.Text) { - // WHITESPACE CONSIDERATIONS --- // truly empty nodes, should just be ignored if (node.text.trim() == "" && node.text.indexOf(" ") == -1) { @@ -274,44 +299,50 @@ class HtmlRichTextParser extends StatelessWidget{ } // empty strings of whitespace might be significant or not, condense it by default - if (node.text.trim() == "" && node.text.indexOf(" ") != -1 && parseContext.condenseWhitespace) { + if (node.text.trim() == "" && + node.text.indexOf(" ") != -1 && + parseContext.condenseWhitespace) { node.text = " "; } // we might want to preserve internal whitespace - String finalText = parseContext.condenseWhitespace ? condenseHtmlWhitespace(node.text) : node.text; + String finalText = parseContext.condenseWhitespace + ? condenseHtmlWhitespace(node.text) + : node.text; // if this is part of a string of spans, we will preserve leading and trailing whitespace - if (!(parseContext.parentElement is TextSpan || parseContext.parentElement is LinkTextSpan)) + if (!(parseContext.parentElement is TextSpan || + parseContext.parentElement is LinkTextSpan)) finalText = finalText.trim(); - // NOW WE HAVE OUR TRULY FINAL TEXT - debugPrint("Plain Text Node: '$finalText'"); + // debugPrint("Plain Text Node: '$finalText'"); // create a span by default - TextSpan span = TextSpan(text:finalText, children:[], style:parseContext.childStyle); + TextSpan span = TextSpan( + text: finalText, + children: [], + style: parseContext.childStyle); // in this class, a ParentElement must be a BlockText, LinkTextSpan, Row, Column, TextSpan // if there is no parentElement, contain the span in a BlockText - if (parseContext.parentElement == null){ + if (parseContext.parentElement == null) { parseContext.parentElement = span; - parseContext.rootWidgetList.add(BlockText(child:RichText(text: span))); + parseContext.rootWidgetList.add(BlockText(child: RichText(text: span))); - // if the parent is a LinkTextSpan, keep the main attributes of that span going. - } else if (parseContext.parentElement is LinkTextSpan){ + // if the parent is a LinkTextSpan, keep the main attributes of that span going. + } else if (parseContext.parentElement is LinkTextSpan) { // add this node to the parent as another LinkTextSpan - parseContext.parentElement.children.add( - LinkTextSpan( - style: parseContext.parentElement.style.merge(parseContext.childStyle), - url: parseContext.parentElement.url, - text: finalText, - onLinkTap: onLinkTap, - ) - ); - - // if the parent is a normal span, just add this to that list + parseContext.parentElement.children.add(LinkTextSpan( + style: + parseContext.parentElement.style.merge(parseContext.childStyle), + url: parseContext.parentElement.url, + text: finalText, + onLinkTap: onLinkTap, + )); + + // if the parent is a normal span, just add this to that list } else { parseContext.parentElement.children.add(span); } @@ -320,12 +351,12 @@ class HtmlRichTextParser extends StatelessWidget{ // OTHER ELEMENT NODES else if (node is dom.Element) { assert(() { - debugPrint("Found ${node.localName}"); - debugPrint(node.outerHtml); + // debugPrint("Found ${node.localName}"); + // debugPrint(node.outerHtml); return true; }()); - if (! _supportedElements.contains(node.localName)) { + if (!_supportedElements.contains(node.localName)) { return; } @@ -334,24 +365,27 @@ class HtmlRichTextParser extends StatelessWidget{ ParseContext nextContext = new ParseContext.fromContext(parseContext); // handle style elements - if ( _supportedStyleElements.contains(node.localName)) { + if (_supportedStyleElements.contains(node.localName)) { TextStyle childStyle = parseContext.childStyle ?? TextStyle(); switch (node.localName) { //"b","i","em","strong","code","u","small","abbr","acronym" case "b": case "strong": - childStyle = childStyle.merge(TextStyle(fontWeight: FontWeight.bold)); + childStyle = + childStyle.merge(TextStyle(fontWeight: FontWeight.bold)); break; case "i": case "em": - childStyle = childStyle.merge(TextStyle(fontStyle: FontStyle.italic)); + childStyle = + childStyle.merge(TextStyle(fontStyle: FontStyle.italic)); break; case "code": childStyle = childStyle.merge(TextStyle(fontFamily: 'monospace')); break; case "u": - childStyle = childStyle.merge(TextStyle(decoration: TextDecoration.underline)); + childStyle = childStyle + .merge(TextStyle(decoration: TextDecoration.underline)); break; case "abbr": case "acronym": @@ -384,7 +418,7 @@ class HtmlRichTextParser extends StatelessWidget{ } // handle specialty elements - else if ( _supportedSpecialtyElements.contains(node.localName)) { + else if (_supportedSpecialtyElements.contains(node.localName)) { // should support "a","br","table","tbody","thead","tfoot","th","tr","td" switch (node.localName) { @@ -397,33 +431,32 @@ class HtmlRichTextParser extends StatelessWidget{ if (_hasBlockChild(node)) { LinkBlock linkContainer = LinkBlock( url: url, - margin: EdgeInsets.only(left:parseContext.indentLevel * indentSize), + margin: EdgeInsets.only( + left: parseContext.indentLevel * indentSize), onLinkTap: onLinkTap, children: [], ); nextContext.parentElement = linkContainer; nextContext.rootWidgetList.add(linkContainer); - } - else { - TextStyle linkStyle = parseContext.childStyle.merge( - TextStyle( - decoration: TextDecoration.underline, - color: Colors.blueAccent, - decorationColor: Colors.blueAccent, - ) - ); + } else { + TextStyle linkStyle = parseContext.childStyle.merge(TextStyle( + decoration: TextDecoration.underline, + color: Colors.blueAccent, + decorationColor: Colors.blueAccent, + )); LinkTextSpan span = LinkTextSpan( style: linkStyle, url: url, onLinkTap: onLinkTap, + children: [], ); - if (parseContext.parentElement is TextSpan){ + if (parseContext.parentElement is TextSpan) { nextContext.parentElement.children.add(span); - } - else { + } else { // start a new block element for this link and its text BlockText blockElement = BlockText( - margin: EdgeInsets.only(left:parseContext.indentLevel * indentSize, top:10.0), + margin: EdgeInsets.only( + left: parseContext.indentLevel * indentSize, top: 10.0), child: RichText(text: span), ); parseContext.rootWidgetList.add(blockElement); @@ -434,17 +467,21 @@ class HtmlRichTextParser extends StatelessWidget{ break; case "br": - if (parseContext.parentElement != null && parseContext.parentElement is TextSpan) { - parseContext.parentElement.children.add(TextSpan(text:'\n', children: [])); + if (parseContext.parentElement != null && + parseContext.parentElement is TextSpan) { + parseContext.parentElement.children + .add(TextSpan(text: '\n', children: [])); } break; - + case "table": case "tbody": case "thead": // new block, so clear out the parent element parseContext.parentElement = null; - nextContext.parentElement = Column(crossAxisAlignment: CrossAxisAlignment.start,); + nextContext.parentElement = Column( + crossAxisAlignment: CrossAxisAlignment.start, + ); nextContext.rootWidgetList.add(nextContext.parentElement); break; @@ -463,7 +500,9 @@ class HtmlRichTextParser extends StatelessWidget{ break; case "tr": - Row row = Row(crossAxisAlignment: CrossAxisAlignment.center,); + Row row = Row( + crossAxisAlignment: CrossAxisAlignment.center, + ); nextContext.parentElement.children.add(row); nextContext.parentElement = row; break; @@ -471,8 +510,7 @@ class HtmlRichTextParser extends StatelessWidget{ } // handle block elements - else if ( _supportedBlockElements.contains(node.localName)) { - + else if (_supportedBlockElements.contains(node.localName)) { // block elements only show up at the "root" widget level // so if we have a block element, reset the parentElement to null parseContext.parentElement = null; @@ -480,17 +518,22 @@ class HtmlRichTextParser extends StatelessWidget{ switch (node.localName) { case "hr": - parseContext.rootWidgetList.add(Divider(height:1.0, color:Colors.black38)); + parseContext.rootWidgetList + .add(Divider(height: 1.0, color: Colors.black38)); break; case "img": if (node.attributes['src'] != null) { - parseContext.rootWidgetList.add(Image.network(node.attributes['src'])); + parseContext.rootWidgetList + .add(Image.network(node.attributes['src'])); } else if (node.attributes['alt'] != null) { parseContext.rootWidgetList.add(BlockText( - margin:EdgeInsets.symmetric(horizontal: 0.0, vertical: 10.0), - padding:EdgeInsets.all(0.0), - child:RichText(text:TextSpan(text: node.attributes['alt'], children:[],)) - )); + margin: EdgeInsets.symmetric(horizontal: 0.0, vertical: 10.0), + padding: EdgeInsets.all(0.0), + child: RichText( + text: TextSpan( + text: node.attributes['alt'], + children: [], + )))); } break; case "li": @@ -502,11 +545,12 @@ class HtmlRichTextParser extends StatelessWidget{ leadingChar = parseContext.listCount.toString() + '.'; } BlockText blockText = BlockText( - margin:EdgeInsets.only(left: parseContext.indentLevel * indentSize, top:3.0), + margin: EdgeInsets.only( + left: parseContext.indentLevel * indentSize, top: 3.0), child: RichText( - text:TextSpan( - text:'', - style:nextContext.childStyle, + text: TextSpan( + text: '', + style: nextContext.childStyle, children: [], ), ), @@ -516,7 +560,7 @@ class HtmlRichTextParser extends StatelessWidget{ nextContext.parentElement = blockText.child.text; nextContext.spansOnly = true; break; - + case "h1": nextContext.childStyle = nextContext.childStyle.merge( TextStyle(fontSize: 26.0, fontWeight: FontWeight.bold), @@ -562,20 +606,23 @@ class HtmlRichTextParser extends StatelessWidget{ Decoration decoration; if (parseContext.blockType == 'blockquote') { decoration = BoxDecoration( - border: Border(left: BorderSide(color:Colors.black38, width:2.0)), + border: + Border(left: BorderSide(color: Colors.black38, width: 2.0)), ); nextContext.childStyle = nextContext.childStyle.merge(TextStyle( fontStyle: FontStyle.italic, )); } BlockText blockText = BlockText( - margin:EdgeInsets.symmetric(vertical:8.0), - padding:EdgeInsets.only(left: parseContext.indentLevel * indentSize,), - decoration:decoration, + margin: EdgeInsets.symmetric(vertical: 8.0), + padding: EdgeInsets.only( + left: parseContext.indentLevel * indentSize, + ), + decoration: decoration, child: RichText( textAlign: textAlign, - text:TextSpan( - text:'', + text: TextSpan( + text: '', style: nextContext.childStyle, children: [], ), @@ -587,7 +634,7 @@ class HtmlRichTextParser extends StatelessWidget{ } } - node.nodes.forEach((dom.Node childNode){ + node.nodes.forEach((dom.Node childNode) { _parseNode(childNode, nextContext); }); } @@ -654,9 +701,6 @@ class HtmlRichTextParser extends StatelessWidget{ } } - - - class HtmlOldParser extends StatelessWidget { HtmlOldParser({ @required this.width, @@ -778,7 +822,7 @@ class HtmlOldParser extends StatelessWidget { if (node is dom.Element) { assert(() { - debugPrint("Found ${node.localName}"); + // debugPrint("Found ${node.localName}"); return true; }()); @@ -1413,7 +1457,7 @@ class HtmlOldParser extends StatelessWidget { node.text = " "; } - debugPrint("Plain Text Node: '${trimStringHtml(node.text)}'"); + // debugPrint("Plain Text Node: '${trimStringHtml(node.text)}'"); String finalText = trimStringHtml(node.text); //Temp fix for https://github.com/flutter/flutter/issues/736 if (finalText.endsWith(" ")) { diff --git a/test/html_parser_test.dart b/test/html_parser_test.dart index af06eeecc1..c86c4662f1 100644 --- a/test/html_parser_test.dart +++ b/test/html_parser_test.dart @@ -4,10 +4,10 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_html/flutter_html.dart'; void main() { - test('Checks that `parse` does not throw an exception', () { - final elementList = HtmlOldParser(width: 42.0).parse("Bold Text"); - expect(elementList, isNotNull); - }); + // test('Checks that `parse` does not throw an exception', () { + // final elementList = HtmlOldParser(width: 42.0).parse("Bold Text"); + // expect(elementList, isNotNull); + // }); testWidgets('Tests some plain old text', (WidgetTester tester) async { await tester.pumpWidget(MaterialApp( From dc9207710760578e070e6939c235e2283ab7816b Mon Sep 17 00:00:00 2001 From: Jeff Mikels Date: Thu, 31 Jan 2019 14:04:32 -0500 Subject: [PATCH 5/5] plain text nodes inside blockquote, ol, and ul, are now properly indented --- lib/html_parser.dart | 71 +++++++++++++++++++++++++++++++++++++------- 1 file changed, 60 insertions(+), 11 deletions(-) diff --git a/lib/html_parser.dart b/lib/html_parser.dart index 706e434a77..8b36b8dac6 100644 --- a/lib/html_parser.dart +++ b/lib/html_parser.dart @@ -102,6 +102,7 @@ class ParseContext { String blockType; // blockType can be 'p', 'div', 'ul', 'ol', 'blockquote' bool condenseWhitespace = true; bool spansOnly = false; + bool inBlock = false; TextStyle childStyle; ParseContext( @@ -113,6 +114,7 @@ class ParseContext { this.blockType, this.condenseWhitespace = true, this.spansOnly = false, + this.inBlock = false, this.childStyle}) { childStyle = childStyle ?? TextStyle(); } @@ -126,6 +128,7 @@ class ParseContext { blockType = parseContext.blockType; condenseWhitespace = parseContext.condenseWhitespace; spansOnly = parseContext.spansOnly; + inBlock = parseContext.inBlock; childStyle = parseContext.childStyle ?? TextStyle(); } } @@ -298,14 +301,14 @@ class HtmlRichTextParser extends StatelessWidget { return; } - // empty strings of whitespace might be significant or not, condense it by default - if (node.text.trim() == "" && - node.text.indexOf(" ") != -1 && - parseContext.condenseWhitespace) { - node.text = " "; - } + // if (node.text.trim() == "" && + // node.text.indexOf(" ") != -1 && + // parseContext.condenseWhitespace) { + // node.text = " "; + // } // we might want to preserve internal whitespace + // empty strings of whitespace might be significant or not, condense it by default String finalText = parseContext.condenseWhitespace ? condenseHtmlWhitespace(node.text) : node.text; @@ -315,6 +318,9 @@ class HtmlRichTextParser extends StatelessWidget { parseContext.parentElement is LinkTextSpan)) finalText = finalText.trim(); + // if the finalText is actually empty, just return + if (finalText.isEmpty) return; + // NOW WE HAVE OUR TRULY FINAL TEXT // debugPrint("Plain Text Node: '$finalText'"); @@ -326,10 +332,48 @@ class HtmlRichTextParser extends StatelessWidget { // in this class, a ParentElement must be a BlockText, LinkTextSpan, Row, Column, TextSpan + // the parseContext might actually be a block level style element, so we + // need to honor the indent and styling specified by that block style. + // e.g. ol, ul, blockquote + bool treatLikeBlock = + ['blockquote', 'ul', 'ol'].indexOf(parseContext.blockType) != -1; + // if there is no parentElement, contain the span in a BlockText if (parseContext.parentElement == null) { + // if this is inside a context that should be treated like a block + // but the context is not actually a block, create a block + // and append it to the root widget tree + if (treatLikeBlock) { + Decoration decoration; + if (parseContext.blockType == 'blockquote') { + decoration = BoxDecoration( + border: + Border(left: BorderSide(color: Colors.black38, width: 2.0)), + ); + parseContext.childStyle = parseContext.childStyle.merge(TextStyle( + fontStyle: FontStyle.italic, + )); + } + BlockText blockText = BlockText( + margin: EdgeInsets.only( + top: 8.0, + bottom: 8.0, + left: parseContext.indentLevel * indentSize), + padding: EdgeInsets.all(2.0), + decoration: decoration, + child: RichText( + textAlign: TextAlign.left, + text: span, + ), + ); + parseContext.rootWidgetList.add(blockText); + } else { + parseContext.rootWidgetList + .add(BlockText(child: RichText(text: span))); + } + + // this allows future items to be added as children parseContext.parentElement = span; - parseContext.rootWidgetList.add(BlockText(child: RichText(text: span))); // if the parent is a LinkTextSpan, keep the main attributes of that span going. } else if (parseContext.parentElement is LinkTextSpan) { @@ -348,6 +392,7 @@ class HtmlRichTextParser extends StatelessWidget { } return; } + // OTHER ELEMENT NODES else if (node is dom.Element) { assert(() { @@ -460,6 +505,7 @@ class HtmlRichTextParser extends StatelessWidget { child: RichText(text: span), ); parseContext.rootWidgetList.add(blockElement); + nextContext.inBlock = true; } nextContext.childStyle = linkStyle; nextContext.parentElement = span; @@ -559,6 +605,7 @@ class HtmlRichTextParser extends StatelessWidget { parseContext.rootWidgetList.add(blockText); nextContext.parentElement = blockText.child.text; nextContext.spansOnly = true; + nextContext.inBlock = true; break; case "h1": @@ -614,10 +661,11 @@ class HtmlRichTextParser extends StatelessWidget { )); } BlockText blockText = BlockText( - margin: EdgeInsets.symmetric(vertical: 8.0), - padding: EdgeInsets.only( - left: parseContext.indentLevel * indentSize, - ), + margin: EdgeInsets.only( + top: 8.0, + bottom: 8.0, + left: parseContext.indentLevel * indentSize), + padding: EdgeInsets.all(2.0), decoration: decoration, child: RichText( textAlign: textAlign, @@ -631,6 +679,7 @@ class HtmlRichTextParser extends StatelessWidget { parseContext.rootWidgetList.add(blockText); nextContext.parentElement = blockText.child.text; nextContext.spansOnly = true; + nextContext.inBlock = true; } }