Inline images (#14)
* Add inline image elements
- Remove 'img' tag from the list of block tags.
- Use a Wrap widget to layout RichText and Image widgets.
- Convert _inlineElements list to accept Objects so both TextSpan and Image widgets can be added.
* Update tests
* Fix Image as child of text node
* Modify _InlineElement to inherit styling and merge spans
* Update tests
* Update tests
* Add test for nested inline images
* Refactor common _addParentInlineIfNeeded code
* Document _InlineElement class
* Document _InlineElement TextStyle merging
* Use dummy image url in tests
diff --git a/packages/flutter_markdown/lib/src/builder.dart b/packages/flutter_markdown/lib/src/builder.dart
index c998e08..3d314f4 100644
--- a/packages/flutter_markdown/lib/src/builder.dart
+++ b/packages/flutter_markdown/lib/src/builder.dart
@@ -18,7 +18,6 @@
'h6',
'li',
'blockquote',
- 'img',
'pre',
'ol',
'ul',
@@ -38,8 +37,27 @@
int nextListIndex = 0;
}
+/// A collection of widgets that should be placed adjacent to (inline with)
+/// other inline elements in the same parent block.
+///
+/// Inline elements can be textual (a/em/strong) represented by [RichText]
+/// widgets or images (img) represented by [Image.network] widgets.
+///
+/// Inline elements can be nested within other inline elements, inheriting their
+/// parent's style along with the style of the block they are in.
+///
+/// When laying out inline widgets, first, any adjacent RichText widgets are
+/// merged, then, all inline widgets are enclosed in a parent [Wrap] widget.
class _InlineElement {
- final List<TextSpan> children = <TextSpan>[];
+ _InlineElement(this.tag, {this.style});
+
+ final String tag;
+
+ /// Created by merging the style defined for this element's [tag] in the
+ /// delegate's [MarkdownStyleSheet] with the style of its parent.
+ final TextStyle style;
+
+ final List<Widget> children = <Widget>[];
}
/// A delegate used by [MarkdownBuilder] to control the widgets it creates.
@@ -86,14 +104,13 @@
_linkHandlers.clear();
_blocks.add(new _BlockElement(null));
- _inlines.add(new _InlineElement());
for (md.Node node in nodes) {
assert(_blocks.length == 1);
node.accept(this);
}
- assert(_inlines.single.children.isEmpty);
+ assert(_inlines.isEmpty);
return _blocks.single.children;
}
@@ -101,13 +118,18 @@
void visitText(md.Text text) {
if (_blocks.last.tag == null) // Don't allow text directly under the root.
return;
+
+ _addParentInlineIfNeeded(_blocks.last.tag);
+
final TextSpan span = _blocks.last.tag == 'pre'
? delegate.formatText(styleSheet, text.text)
: new TextSpan(
+ style: _inlines.last.style,
text: text.text,
recognizer: _linkHandlers.isNotEmpty ? _linkHandlers.last : null,
);
- _inlines.last.children.add(span);
+
+ _inlines.last.children.add(new RichText(text: span));
}
@override
@@ -119,7 +141,13 @@
_listIndents.add(tag);
_blocks.add(new _BlockElement(tag));
} else {
- _inlines.add(new _InlineElement());
+ _addParentInlineIfNeeded(_blocks.last.tag);
+
+ TextStyle parentStyle = _inlines.last.style;
+ _inlines.add(new _InlineElement(
+ tag,
+ style: parentStyle.merge(styleSheet.styles[tag]),
+ ));
}
if (tag == 'a') {
@@ -138,51 +166,48 @@
final _BlockElement current = _blocks.removeLast();
Widget child;
- if (tag == 'img') {
- child = _buildImage(element.attributes['src']);
- } else {
- if (current.children.isNotEmpty) {
- child = new Column(
- crossAxisAlignment: CrossAxisAlignment.stretch,
- children: current.children,
- );
- } else {
- child = const SizedBox();
- }
- if (_isListTag(tag)) {
- assert(_listIndents.isNotEmpty);
- _listIndents.removeLast();
- } else if (tag == 'li') {
- if (_listIndents.isNotEmpty) {
- child = new Row(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: <Widget>[
- new SizedBox(
- width: styleSheet.listIndent,
- child: _buildBullet(_listIndents.last),
- ),
- new Expanded(child: child)
- ],
- );
- }
- } else if (tag == 'blockquote') {
- child = new DecoratedBox(
- decoration: styleSheet.blockquoteDecoration,
- child: new Padding(
- padding: new EdgeInsets.all(styleSheet.blockquotePadding),
- child: child,
- ),
- );
- } else if (tag == 'pre') {
- child = new DecoratedBox(
- decoration: styleSheet.codeblockDecoration,
- child: new Padding(
- padding: new EdgeInsets.all(styleSheet.codeblockPadding),
- child: child,
- ),
+ if (current.children.isNotEmpty) {
+ child = new Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: current.children,
+ );
+ } else {
+ child = const SizedBox();
+ }
+
+ if (_isListTag(tag)) {
+ assert(_listIndents.isNotEmpty);
+ _listIndents.removeLast();
+ } else if (tag == 'li') {
+ if (_listIndents.isNotEmpty) {
+ child = new Row(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: <Widget>[
+ new SizedBox(
+ width: styleSheet.listIndent,
+ child: _buildBullet(_listIndents.last),
+ ),
+ new Expanded(child: child)
+ ],
);
}
+ } else if (tag == 'blockquote') {
+ child = new DecoratedBox(
+ decoration: styleSheet.blockquoteDecoration,
+ child: new Padding(
+ padding: new EdgeInsets.all(styleSheet.blockquotePadding),
+ child: child,
+ ),
+ );
+ } else if (tag == 'pre') {
+ child = new DecoratedBox(
+ decoration: styleSheet.codeblockDecoration,
+ child: new Padding(
+ padding: new EdgeInsets.all(styleSheet.codeblockPadding),
+ child: child,
+ ),
+ );
}
_addBlockChild(child);
@@ -190,15 +215,15 @@
final _InlineElement current = _inlines.removeLast();
final _InlineElement parent = _inlines.last;
- if (current.children.isNotEmpty) {
- parent.children.add(new TextSpan(
- style: styleSheet.styles[tag],
- children: current.children,
- ));
+ if (tag == 'img') {
+ // create an image widget for this image
+ current.children.add(_buildImage(element.attributes['src']));
+ } else if (tag == 'a') {
+ _linkHandlers.removeLast();
+ }
- if (tag == 'a') {
- _linkHandlers.removeLast();
- }
+ if (current.children.isNotEmpty) {
+ parent.children.addAll(current.children);
}
}
}
@@ -233,6 +258,15 @@
);
}
+ void _addParentInlineIfNeeded(String tag) {
+ if (_inlines.isEmpty) {
+ _inlines.add(new _InlineElement(
+ tag,
+ style: styleSheet.styles[tag],
+ ));
+ }
+ }
+
void _addBlockChild(Widget child) {
final _BlockElement parent = _blocks.last;
if (parent.children.isNotEmpty)
@@ -242,12 +276,35 @@
}
void _addAnonymousBlockIfNeeded(TextStyle style) {
+ if (_inlines.isEmpty) {
+ return;
+ }
+
final _InlineElement inline = _inlines.single;
if (inline.children.isNotEmpty) {
- final TextSpan span = new TextSpan(style: style, children: inline.children);
- _addBlockChild(new RichText(text: span));
+ List<Widget> mergedInlines = _mergeInlineChildren(inline);
+ final Wrap wrap = new Wrap(children: mergedInlines);
+ _addBlockChild(wrap);
_inlines.clear();
- _inlines.add(new _InlineElement());
}
}
+
+ /// Merges adjacent [TextSpan] children of the given [_InlineElement]
+ List<Widget> _mergeInlineChildren(_InlineElement inline) {
+ List<Widget> mergedTexts = <Widget>[];
+ for (Widget child in inline.children) {
+ if (mergedTexts.isNotEmpty && mergedTexts.last is RichText && child is RichText) {
+ RichText previous = mergedTexts.removeLast();
+ List<TextSpan> children = previous.text.children != null
+ ? new List.from(previous.text.children)
+ : [previous.text];
+ children.add(child.text);
+ TextSpan mergedSpan = new TextSpan(children: children);
+ mergedTexts.add(new RichText(text: mergedSpan));
+ } else {
+ mergedTexts.add(child);
+ }
+ }
+ return mergedTexts;
+ }
}
diff --git a/packages/flutter_markdown/test/flutter_markdown_test.dart b/packages/flutter_markdown/test/flutter_markdown_test.dart
index 268d140..3900041 100644
--- a/packages/flutter_markdown/test/flutter_markdown_test.dart
+++ b/packages/flutter_markdown/test/flutter_markdown_test.dart
@@ -21,7 +21,7 @@
final Iterable<Widget> widgets = tester.allWidgets;
_expectWidgetTypes(
- widgets, <Type>[Directionality, MarkdownBody, Column, RichText]);
+ widgets, <Type>[Directionality, MarkdownBody, Column, Wrap, RichText]);
_expectTextStrings(widgets, <String>['Hello']);
});
@@ -30,7 +30,7 @@
final Iterable<Widget> widgets = tester.allWidgets;
_expectWidgetTypes(
- widgets, <Type>[Directionality, MarkdownBody, Column, RichText]);
+ widgets, <Type>[Directionality, MarkdownBody, Column, Wrap, RichText]);
_expectTextStrings(widgets, <String>['Header']);
});
@@ -100,12 +100,10 @@
tester.allWidgets.firstWhere((Widget widget) => widget is RichText);
final TextSpan span = textWidget.text;
- (span.children[0].children[0].recognizer as TapGestureRecognizer).onTap();
+ (span.recognizer as TapGestureRecognizer).onTap();
- expect(span.children.length, 1);
- expect(span.children[0].children.length, 1);
- expect(span.children[0].children[0].recognizer.runtimeType,
- equals(TapGestureRecognizer));
+ expect(span.children, null);
+ expect(span.recognizer.runtimeType, equals(TapGestureRecognizer));
expect(tapResult, 'href');
});
@@ -128,8 +126,8 @@
return true;
});
- expect(span.children.length, 1);
- expect(span.children[0].children.length, 3);
+ expect(span.children.length, 3);
+ expect(gestureRecognizerTypes.length, 3);
expect(gestureRecognizerTypes, everyElement(TapGestureRecognizer));
expect(tapResults.length, 3);
expect(tapResults, everyElement('href'));
@@ -155,12 +153,7 @@
return true;
});
-
expect(span.children.length, 3);
- expect(span.children[0].children.length, 1);
- expect(span.children[1].children, null);
- expect(span.children[2].children.length, 1);
-
expect(gestureRecognizerTypes,
orderedEquals([TapGestureRecognizer, Null, TapGestureRecognizer]));
expect(tapResults, orderedEquals(['firstHref', 'secondHref']));
@@ -172,6 +165,26 @@
createHttpClient = createMockImageHttpClient;
});
+ testWidgets('should not interupt styling', (WidgetTester tester) async {
+ await tester.pumpWidget(_boilerplate(const Markdown(
+ data:'_textbefore  textafter_',
+ )));
+
+ final RichText firstTextWidget =
+ tester.allWidgets.firstWhere((Widget widget) => widget is RichText);
+ final Image image =
+ tester.allWidgets.firstWhere((Widget widget) => widget is Image);
+ final NetworkImage networkImage = image.image;
+ final RichText secondTextWidget =
+ tester.allWidgets.lastWhere((Widget widget) => widget is RichText);
+
+ expect(firstTextWidget.text.text, 'textbefore ');
+ expect(firstTextWidget.text.style.fontStyle, FontStyle.italic);
+ expect(networkImage.url,'img');
+ expect(secondTextWidget.text.text, ' textafter');
+ expect(secondTextWidget.text.style.fontStyle, FontStyle.italic);
+ });
+
testWidgets('should work with a link', (WidgetTester tester) async {
await tester
.pumpWidget(_boilerplate(const Markdown(data: '')));
@@ -191,7 +204,7 @@
final RichText richText =
tester.allWidgets.firstWhere((Widget widget) => widget is RichText);
TextSpan textSpan = richText.text;
- expect(textSpan.children[0].text, 'Hello ');
+ expect(textSpan.text, 'Hello ');
expect(textSpan.style, isNotNull);
});
});