Support GitHub Flavored Markdown (#133)
diff --git a/packages/flutter_markdown/example/lib/main.dart b/packages/flutter_markdown/example/lib/main.dart
index ee3a69d..1c7a2aa 100644
--- a/packages/flutter_markdown/example/lib/main.dart
+++ b/packages/flutter_markdown/example/lib/main.dart
@@ -8,6 +8,61 @@
const String _markdownData = """# Markdown Example
Markdown allows you to easily include formatted text, images, and even formatted Dart code in your app.
+## Titles
+
+Setext-style
+
+```
+This is an H1
+=============
+
+This is an H2
+-------------
+```
+
+Atx-style
+
+```
+# This is an H1
+
+## This is an H2
+
+###### This is an H6
+```
+
+Select the valid headers:
+
+- [x] `# hello`
+- [ ] `#hello`
+
+## Links
+
+[Google's Homepage][Google]
+
+```
+[inline-style](https://www.google.com)
+
+[reference-style][Google]
+```
+
+## Images
+
+
+
+## Tables
+
+|Syntax |Result |
+|---------------------------------------|-------------------------------------|
+|`*italic 1*` |*italic 1* |
+|`_italic 2_` | _italic 2_ |
+|`**bold 1**` |**bold 1** |
+|`__bold 2__` |__bold 2__ |
+|`This is a ~~strikethrough~~` |This is a ~~strikethrough~~ |
+|`***italic bold 1***` |***italic bold 1*** |
+|`___italic bold 2___` |___italic bold 2___ |
+|`***~~italic bold strikethrough 1~~***`|***~~italic bold strikethrough 1~~***|
+|`~~***italic bold strikethrough 2***~~`|~~***italic bold strikethrough 2***~~|
+
## Styling
Style text as _italic_, __bold__, ~~strikethrough~~, or `inline code`.
@@ -15,21 +70,6 @@
- To better clarify
- Your points
-## Links
-You can use [hyperlinks](hyperlink) in markdown
-
-## Images
-
-You can include images:
-
-
-
-## Markdown widget
-
-This is an example of how to create your own Markdown widget:
-
- Markdown(data: 'Hello _world_!');
-
## Code blocks
Formatted Dart code looks really pretty too:
@@ -43,7 +83,15 @@
}
```
+## Markdown widget
+
+This is an example of how to create your own Markdown widget:
+
+ Markdown(data: 'Hello _world_!');
+
Enjoy!
+
+[Google]: https://www.google.com/
""";
void main() {
@@ -51,7 +99,10 @@
title: "Markdown Demo",
home: Scaffold(
appBar: AppBar(title: const Text('Markdown Demo')),
- body: const Markdown(data: _markdownData),
+ body: Markdown(
+ data: _markdownData,
+ imageDirectory: 'https://raw.githubusercontent.com',
+ ),
),
));
}
diff --git a/packages/flutter_markdown/lib/src/builder.dart b/packages/flutter_markdown/lib/src/builder.dart
index 42dda80..c6bc4e6 100644
--- a/packages/flutter_markdown/lib/src/builder.dart
+++ b/packages/flutter_markdown/lib/src/builder.dart
@@ -2,16 +2,16 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
-import 'dart:io';
+import 'dart:io' show File;
import 'package:flutter/gestures.dart';
-import 'package:flutter/widgets.dart';
+import 'package:flutter/material.dart';
import 'package:markdown/markdown.dart' as md;
-import 'package:path/path.dart' as p;
import 'style_sheet.dart';
+import 'widget.dart';
-final Set<String> _kBlockTags = Set<String>.from(<String>[
+const List<String> _kBlockTags = const <String>[
'p',
'h1',
'h2',
@@ -25,7 +25,11 @@
'ol',
'ul',
'hr',
-]);
+ 'table',
+ 'thead',
+ 'tbody',
+ 'tr'
+];
const List<String> _kListTags = const <String>['ul', 'ol'];
@@ -42,6 +46,10 @@
int nextListIndex = 0;
}
+class _TableElement {
+ final List<TableRow> rows = <TableRow>[];
+}
+
/// A collection of widgets that should be placed adjacent to (inline with)
/// other inline elements in the same parent block.
///
@@ -85,7 +93,13 @@
/// * [Markdown], which is a widget that parses and displays Markdown.
class MarkdownBuilder implements md.NodeVisitor {
/// Creates an object that builds a [Widget] tree from parsed Markdown.
- MarkdownBuilder({this.delegate, this.styleSheet, this.imageDirectory});
+ MarkdownBuilder({
+ this.delegate,
+ this.styleSheet,
+ this.imageDirectory,
+ this.imageBuilder,
+ this.checkboxBuilder,
+ });
/// A delegate that controls how link and `pre` elements behave.
final MarkdownBuilderDelegate delegate;
@@ -93,11 +107,18 @@
/// Defines which [TextStyle] objects to use for each type of element.
final MarkdownStyleSheet styleSheet;
- /// The base directory holding images referenced by Img tags with local file paths.
- final Directory imageDirectory;
+ /// The base directory holding images referenced by Img tags with local or network file paths.
+ final String imageDirectory;
+
+ /// Call when build an image widget.
+ final MarkdownImageBuilder imageBuilder;
+
+ /// Call when build a checkbox widget.
+ final MarkdownCheckboxBuilder checkboxBuilder;
final List<String> _listIndents = <String>[];
final List<_BlockElement> _blocks = <_BlockElement>[];
+ final List<_TableElement> _tables = <_TableElement>[];
final List<_InlineElement> _inlines = <_InlineElement>[];
final List<GestureRecognizer> _linkHandlers = <GestureRecognizer>[];
@@ -107,6 +128,7 @@
List<Widget> build(List<md.Node> nodes) {
_listIndents.clear();
_blocks.clear();
+ _tables.clear();
_inlines.clear();
_linkHandlers.clear();
@@ -117,6 +139,7 @@
node.accept(this);
}
+ assert(_tables.isEmpty);
assert(_inlines.isEmpty);
return _blocks.single.children;
}
@@ -128,18 +151,26 @@
_addParentInlineIfNeeded(_blocks.last.tag);
- final TextSpan span = _blocks.last.tag == 'pre'
- ? delegate.formatText(styleSheet, text.text)
- : TextSpan(
- style: _inlines.last.style,
- text: text.text,
- recognizer: _linkHandlers.isNotEmpty ? _linkHandlers.last : null,
- );
-
- _inlines.last.children.add(RichText(
- textScaleFactor: styleSheet.textScaleFactor,
- text: span,
- ));
+ Widget child;
+ if (_blocks.last.tag == 'pre') {
+ child = Scrollbar(
+ child: SingleChildScrollView(
+ scrollDirection: Axis.horizontal,
+ padding: styleSheet.codeblockPadding,
+ child: RichText(text: delegate.formatText(styleSheet, text.text)),
+ ),
+ );
+ } else {
+ child = RichText(
+ textScaleFactor: styleSheet.textScaleFactor,
+ text: TextSpan(
+ style: _inlines.last.style,
+ text: text.text,
+ recognizer: _linkHandlers.isNotEmpty ? _linkHandlers.last : null,
+ ),
+ );
+ }
+ _inlines.last.children.add(child);
}
@override
@@ -147,7 +178,19 @@
final String tag = element.tag;
if (_isBlockTag(tag)) {
_addAnonymousBlockIfNeeded(styleSheet.styles[tag]);
- if (_isListTag(tag)) _listIndents.add(tag);
+ if (_isListTag(tag)) {
+ _listIndents.add(tag);
+ } else if (tag == 'table') {
+ _tables.add(_TableElement());
+ } else if (tag == 'tr') {
+ final length = _tables.single.rows.length;
+ BoxDecoration decoration = styleSheet.tableCellsDecoration;
+ if (length == 0 || length % 2 == 1) decoration = null;
+ _tables.single.rows.add(TableRow(
+ decoration: decoration,
+ children: <Widget>[],
+ ));
+ }
_blocks.add(_BlockElement(tag));
} else {
_addParentInlineIfNeeded(_blocks.last.tag);
@@ -190,17 +233,31 @@
_listIndents.removeLast();
} else if (tag == 'li') {
if (_listIndents.isNotEmpty) {
+ Widget bullet;
+ dynamic el = element.children[0];
+ if (el is md.Element && el.attributes['type'] == 'checkbox') {
+ bool val = el.attributes['checked'] != 'false';
+ bullet = _buildCheckbox(val);
+ } else {
+ bullet = _buildBullet(_listIndents.last);
+ }
child = Row(
- crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
SizedBox(
width: styleSheet.listIndent,
- child: _buildBullet(_listIndents.last),
+ child: bullet,
),
Expanded(child: child)
],
);
}
+ } else if (tag == 'table') {
+ child = Table(
+ defaultColumnWidth: IntrinsicColumnWidth(),
+ defaultVerticalAlignment: TableCellVerticalAlignment.middle,
+ border: styleSheet.tableBorder,
+ children: _tables.removeLast().rows,
+ );
} else if (tag == 'blockquote') {
child = DecoratedBox(
decoration: styleSheet.blockquoteDecoration,
@@ -212,10 +269,7 @@
} else if (tag == 'pre') {
child = DecoratedBox(
decoration: styleSheet.codeblockDecoration,
- child: Padding(
- padding: styleSheet.codeblockPadding,
- child: child,
- ),
+ child: child,
);
} else if (tag == 'hr') {
child = DecoratedBox(
@@ -232,6 +286,33 @@
if (tag == 'img') {
// create an image widget for this image
current.children.add(_buildImage(element.attributes['src']));
+ } else if (tag == 'br') {
+ current.children.add(RichText(text: const TextSpan(text: '\n')));
+ } else if (tag == 'th' || tag == 'td') {
+ TextAlign align;
+ String style = element.attributes['style'];
+ if (style == null) {
+ align = tag == 'th' ? styleSheet.tableHeadAlign : TextAlign.left;
+ } else {
+ RegExp regExp = RegExp(r'text-align: (left|center|right)');
+ Match match = regExp.matchAsPrefix(style);
+ switch (match[1]) {
+ case 'left':
+ align = TextAlign.left;
+ break;
+ case 'center':
+ align = TextAlign.center;
+ break;
+ case 'right':
+ align = TextAlign.right;
+ break;
+ }
+ }
+ Widget child = _buildTableCell(
+ _mergeInlineChildren(current.children),
+ textAlign: align,
+ );
+ _tables.single.rows.last.children.add(child);
} else if (tag == 'a') {
_linkHandlers.removeLast();
}
@@ -259,17 +340,23 @@
Uri uri = Uri.parse(path);
Widget child;
- if (uri.scheme == 'http' || uri.scheme == 'https') {
+ if (imageBuilder != null) {
+ child = imageBuilder(uri);
+ } else if (uri.scheme == 'http' || uri.scheme == 'https') {
child = Image.network(uri.toString(), width: width, height: height);
} else if (uri.scheme == 'data') {
child = _handleDataSchemeUri(uri, width, height);
} else if (uri.scheme == "resource") {
child = Image.asset(path.substring(9), width: width, height: height);
} else {
- String filePath = (imageDirectory == null
- ? uri.toFilePath()
- : p.join(imageDirectory.path, uri.toFilePath()));
- child = Image.file(File(filePath), width: width, height: height);
+ Uri fileUri = imageDirectory != null
+ ? Uri.parse(imageDirectory + uri.toString())
+ : uri;
+ if (fileUri.scheme == 'http' || fileUri.scheme == 'https') {
+ child = Image.network(fileUri.toString(), width: width, height: height);
+ } else {
+ child = Image.file(File.fromUri(fileUri), width: width, height: height);
+ }
}
if (_linkHandlers.isNotEmpty) {
@@ -295,21 +382,49 @@
return const SizedBox();
}
+ Widget _buildCheckbox(bool checked) {
+ if (checkboxBuilder != null) {
+ return checkboxBuilder(checked);
+ }
+ return Padding(
+ padding: const EdgeInsets.only(right: 4),
+ child: Icon(
+ checked ? Icons.check_box : Icons.check_box_outline_blank,
+ size: styleSheet.checkbox.fontSize,
+ color: styleSheet.checkbox.color,
+ ),
+ );
+ }
+
Widget _buildBullet(String listTag) {
- if (listTag == 'ul')
+ if (listTag == 'ul') {
return Text(
'•',
textAlign: TextAlign.center,
- style: styleSheet.styles['li'],
+ style: styleSheet.listBullet,
);
+ }
final int index = _blocks.last.nextListIndex;
return Padding(
- padding: const EdgeInsets.only(right: 5.0),
+ padding: const EdgeInsets.only(right: 4),
child: Text(
'${index + 1}.',
textAlign: TextAlign.right,
- style: styleSheet.styles['li'],
+ style: styleSheet.listBullet,
+ ),
+ );
+ }
+
+ Widget _buildTableCell(List<Widget> children, {TextAlign textAlign}) {
+ return TableCell(
+ child: Padding(
+ padding: styleSheet.tableCellsPadding,
+ child: DefaultTextStyle(
+ style: styleSheet.tableBody,
+ textAlign: textAlign,
+ child: Wrap(children: children),
+ ),
),
);
}
@@ -337,8 +452,9 @@
final _InlineElement inline = _inlines.single;
if (inline.children.isNotEmpty) {
- List<Widget> mergedInlines = _mergeInlineChildren(inline);
+ List<Widget> mergedInlines = _mergeInlineChildren(inline.children);
final Wrap wrap = Wrap(
+ crossAxisAlignment: WrapCrossAlignment.center,
children: mergedInlines,
);
_addBlockChild(wrap);
@@ -346,10 +462,10 @@
}
}
- /// Merges adjacent [TextSpan] children of the given [_InlineElement]
- List<Widget> _mergeInlineChildren(_InlineElement inline) {
+ /// Merges adjacent [TextSpan] children
+ List<Widget> _mergeInlineChildren(List<Widget> children) {
List<Widget> mergedTexts = <Widget>[];
- for (Widget child in inline.children) {
+ for (Widget child in children) {
if (mergedTexts.isNotEmpty &&
mergedTexts.last is RichText &&
child is RichText) {
diff --git a/packages/flutter_markdown/lib/src/style_sheet.dart b/packages/flutter_markdown/lib/src/style_sheet.dart
index 87a48e9..70f5f88 100644
--- a/packages/flutter_markdown/lib/src/style_sheet.dart
+++ b/packages/flutter_markdown/lib/src/style_sheet.dart
@@ -22,8 +22,16 @@
this.del,
this.blockquote,
this.img,
+ this.checkbox,
this.blockSpacing,
this.listIndent,
+ this.listBullet,
+ this.tableHead,
+ this.tableBody,
+ this.tableHeadAlign,
+ this.tableBorder,
+ this.tableCellsPadding,
+ this.tableCellsDecoration,
this.blockquotePadding,
this.blockquoteDecoration,
this.codeblockPadding,
@@ -47,6 +55,10 @@
'del': del,
'blockquote': blockquote,
'img': img,
+ 'table': p,
+ 'th': tableHead,
+ 'tr': tableBody,
+ 'td': tableBody,
};
/// Creates a [MarkdownStyleSheet] from the [TextStyle]s in the provided [ThemeData].
@@ -55,8 +67,8 @@
return MarkdownStyleSheet(
a: const TextStyle(color: Colors.blue),
p: theme.textTheme.body1,
- code: TextStyle(
- color: Colors.grey.shade700,
+ code: theme.textTheme.body1.copyWith(
+ backgroundColor: Colors.grey.shade200,
fontFamily: "monospace",
fontSize: theme.textTheme.body1.fontSize * 0.85,
),
@@ -71,8 +83,18 @@
del: const TextStyle(decoration: TextDecoration.lineThrough),
blockquote: theme.textTheme.body1,
img: theme.textTheme.body1,
+ checkbox: theme.textTheme.body1.copyWith(
+ color: theme.primaryColor,
+ ),
blockSpacing: 8.0,
- listIndent: 32.0,
+ listIndent: 24.0,
+ listBullet: theme.textTheme.body1,
+ tableHead: const TextStyle(fontWeight: FontWeight.w600),
+ tableBody: theme.textTheme.body1,
+ tableHeadAlign: TextAlign.center,
+ tableBorder: TableBorder.all(color: Colors.grey.shade300, width: 0),
+ tableCellsPadding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
+ tableCellsDecoration: BoxDecoration(color: Colors.grey.shade50),
blockquotePadding: const EdgeInsets.all(8.0),
blockquoteDecoration: BoxDecoration(
color: Colors.blue.shade100,
@@ -80,7 +102,7 @@
),
codeblockPadding: const EdgeInsets.all(8.0),
codeblockDecoration: BoxDecoration(
- color: Colors.grey.shade100,
+ color: Colors.grey.shade200,
borderRadius: BorderRadius.circular(2.0),
),
horizontalRuleDecoration: BoxDecoration(
@@ -99,8 +121,8 @@
return MarkdownStyleSheet(
a: const TextStyle(color: Colors.blue),
p: theme.textTheme.body1,
- code: TextStyle(
- color: Colors.grey.shade700,
+ code: theme.textTheme.body1.copyWith(
+ backgroundColor: Colors.grey.shade200,
fontFamily: "monospace",
fontSize: theme.textTheme.body1.fontSize * 0.85,
),
@@ -115,8 +137,18 @@
del: const TextStyle(decoration: TextDecoration.lineThrough),
blockquote: theme.textTheme.body1,
img: theme.textTheme.body1,
+ checkbox: theme.textTheme.body1.copyWith(
+ color: theme.primaryColor,
+ ),
blockSpacing: 8.0,
- listIndent: 32.0,
+ listIndent: 24.0,
+ listBullet: theme.textTheme.body1,
+ tableHead: const TextStyle(fontWeight: FontWeight.w600),
+ tableBody: theme.textTheme.body1,
+ tableHeadAlign: TextAlign.center,
+ tableBorder: TableBorder.all(color: Colors.grey.shade300),
+ tableCellsPadding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
+ tableCellsDecoration: BoxDecoration(color: Colors.grey.shade50),
blockquotePadding: const EdgeInsets.all(8.0),
blockquoteDecoration: BoxDecoration(
color: Colors.blue.shade100,
@@ -124,7 +156,7 @@
),
codeblockPadding: const EdgeInsets.all(8.0),
codeblockDecoration: BoxDecoration(
- color: Colors.grey.shade100,
+ color: Colors.grey.shade200,
borderRadius: BorderRadius.circular(2.0),
),
horizontalRuleDecoration: BoxDecoration(
@@ -152,8 +184,16 @@
TextStyle del,
TextStyle blockquote,
TextStyle img,
+ TextStyle checkbox,
double blockSpacing,
double listIndent,
+ TextStyle listBullet,
+ TextStyle tableHead,
+ TextStyle tableBody,
+ TextAlign tableHeadAlign,
+ TableBorder tableBorder,
+ EdgeInsets tableCellsPadding,
+ Decoration tableCellsDecoration,
EdgeInsets blockquotePadding,
Decoration blockquoteDecoration,
EdgeInsets codeblockPadding,
@@ -176,8 +216,16 @@
del: del ?? this.del,
blockquote: blockquote ?? this.blockquote,
img: img ?? this.img,
+ checkbox: checkbox ?? this.checkbox,
blockSpacing: blockSpacing ?? this.blockSpacing,
listIndent: listIndent ?? this.listIndent,
+ listBullet: listBullet ?? this.listBullet,
+ tableHead: tableHead ?? this.tableHead,
+ tableBody: tableBody ?? this.tableBody,
+ tableHeadAlign: tableHeadAlign ?? this.tableHeadAlign,
+ tableBorder: tableBorder ?? this.tableBorder,
+ tableCellsPadding: tableCellsPadding ?? this.tableCellsPadding,
+ tableCellsDecoration: tableCellsDecoration ?? this.tableCellsDecoration,
blockquotePadding: blockquotePadding ?? this.blockquotePadding,
blockquoteDecoration: blockquoteDecoration ?? this.blockquoteDecoration,
codeblockPadding: codeblockPadding ?? this.codeblockPadding,
@@ -230,12 +278,36 @@
/// The [TextStyle] to use for `img` elements.
final TextStyle img;
+ /// The [TextStyle] to use for `input` elements.
+ final TextStyle checkbox;
+
/// The amount of vertical space to use between block-level elements.
final double blockSpacing;
/// The amount of horizontal space to indent list items.
final double listIndent;
+ /// The [TextStyle] to use for bullets.
+ final TextStyle listBullet;
+
+ /// The [TextStyle] to use for `th` elements.
+ final TextStyle tableHead;
+
+ /// The [TextStyle] to use for `td` elements.
+ final TextStyle tableBody;
+
+ /// The [TextAlign] to use for `th` elements.
+ final TextAlign tableHeadAlign;
+
+ /// The [TableBorder] to use for `table` elements.
+ final TableBorder tableBorder;
+
+ /// The padding to use for `th` and `td` elements.
+ final EdgeInsets tableCellsPadding;
+
+ /// The decoration to use for `th` and `td` elements.
+ final Decoration tableCellsDecoration;
+
/// The padding to use for `blockquote` elements.
final EdgeInsets blockquotePadding;
@@ -277,8 +349,16 @@
typedOther.del == del &&
typedOther.blockquote == blockquote &&
typedOther.img == img &&
+ typedOther.checkbox == checkbox &&
typedOther.blockSpacing == blockSpacing &&
typedOther.listIndent == listIndent &&
+ typedOther.listBullet == listBullet &&
+ typedOther.tableHead == tableHead &&
+ typedOther.tableBody == tableBody &&
+ typedOther.tableHeadAlign == tableHeadAlign &&
+ typedOther.tableBorder == tableBorder &&
+ typedOther.tableCellsPadding == tableCellsPadding &&
+ typedOther.tableCellsDecoration == tableCellsDecoration &&
typedOther.blockquotePadding == blockquotePadding &&
typedOther.blockquoteDecoration == blockquoteDecoration &&
typedOther.codeblockPadding == codeblockPadding &&
@@ -304,8 +384,16 @@
del,
blockquote,
img,
+ checkbox,
blockSpacing,
listIndent,
+ listBullet,
+ tableHead,
+ tableBody,
+ tableHeadAlign,
+ tableBorder,
+ tableCellsPadding,
+ tableCellsDecoration,
blockquotePadding,
blockquoteDecoration,
codeblockPadding,
diff --git a/packages/flutter_markdown/lib/src/widget.dart b/packages/flutter_markdown/lib/src/widget.dart
index e7ceb11..e13e2e1 100644
--- a/packages/flutter_markdown/lib/src/widget.dart
+++ b/packages/flutter_markdown/lib/src/widget.dart
@@ -2,8 +2,6 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
-import 'dart:io';
-
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:markdown/markdown.dart' as md;
@@ -17,6 +15,16 @@
/// Used by [MarkdownWidget.onTapLink].
typedef void MarkdownTapLinkCallback(String href);
+/// Signature for custom image widget.
+///
+/// Used by [MarkdownWidget.imageBuilder]
+typedef Widget MarkdownImageBuilder(Uri uri);
+
+/// Signature for custom checkbox widget.
+///
+/// Used by [MarkdownWidget.checkboxBuilder]
+typedef Widget MarkdownCheckboxBuilder(bool value);
+
/// Creates a format [TextSpan] given a string.
///
/// Used by [MarkdownWidget] to highlight the contents of `pre` elements.
@@ -29,13 +37,13 @@
/// A base class for widgets that parse and display Markdown.
///
/// Supports all standard Markdown from the original
-/// [Markdown specification](https://daringfireball.net/projects/markdown/).
+/// [Markdown specification](https://github.github.com/gfm/).
///
/// See also:
///
/// * [Markdown], which is a scrolling container of Markdown.
/// * [MarkdownBody], which is a non-scrolling container of Markdown.
-/// * <https://daringfireball.net/projects/markdown/>
+/// * <https://github.github.com/gfm/>
abstract class MarkdownWidget extends StatefulWidget {
/// Creates a widget that parses and displays Markdown.
///
@@ -47,6 +55,9 @@
this.syntaxHighlighter,
this.onTapLink,
this.imageDirectory,
+ this.extensionSet,
+ this.imageBuilder,
+ this.checkboxBuilder,
}) : assert(data != null),
super(key: key);
@@ -66,8 +77,19 @@
/// Called when the user taps a link.
final MarkdownTapLinkCallback onTapLink;
- /// The base directory holding images referenced by Img tags with local file paths.
- final Directory imageDirectory;
+ /// The base directory holding images referenced by Img tags with local or network file paths.
+ final String imageDirectory;
+
+ /// Markdown syntax extension set
+ ///
+ /// Defaults to [md.ExtensionSet.gitHubFlavored]
+ final md.ExtensionSet extensionSet;
+
+ /// Call when build an image widget.
+ final MarkdownImageBuilder imageBuilder;
+
+ /// Call when build a checkbox widget.
+ final MarkdownCheckboxBuilder checkboxBuilder;
/// Subclasses should override this function to display the given children,
/// which are the parsed representation of [data].
@@ -110,7 +132,8 @@
final List<String> lines = widget.data.split(RegExp(r'\r?\n'));
final md.Document document = md.Document(
- inlineSyntaxes: [md.AutolinkExtensionSyntax(), md.StrikethroughSyntax()],
+ extensionSet: widget.extensionSet ?? md.ExtensionSet.gitHubFlavored,
+ inlineSyntaxes: [TaskListSyntax()],
encodeHtml: false,
);
final MarkdownBuilder builder = MarkdownBuilder(
@@ -141,6 +164,7 @@
@override
TextSpan formatText(MarkdownStyleSheet styleSheet, String code) {
+ code = code.replaceAll(RegExp(r'\n$'), '');
if (widget.syntaxHighlighter != null) {
return widget.syntaxHighlighter.format(code);
}
@@ -153,13 +177,13 @@
/// A non-scrolling widget that parses and displays Markdown.
///
-/// Supports all standard Markdown from the original
-/// [Markdown specification](https://daringfireball.net/projects/markdown/).
+/// Supports all GitHub Flavored Markdown from the
+/// [specification](https://github.github.com/gfm/).
///
/// See also:
///
/// * [Markdown], which is a scrolling container of Markdown.
-/// * <https://daringfireball.net/projects/markdown/>
+/// * <https://github.github.com/gfm/>
class MarkdownBody extends MarkdownWidget {
/// Creates a non-scrolling widget that parses and displays Markdown.
const MarkdownBody({
@@ -168,7 +192,11 @@
MarkdownStyleSheet styleSheet,
SyntaxHighlighter syntaxHighlighter,
MarkdownTapLinkCallback onTapLink,
- Directory imageDirectory,
+ String imageDirectory,
+ md.ExtensionSet extensionSet,
+ MarkdownImageBuilder imageBuilder,
+ MarkdownCheckboxBuilder checkboxBuilder,
+ this.shrinkWrap = false,
}) : super(
key: key,
data: data,
@@ -176,12 +204,19 @@
syntaxHighlighter: syntaxHighlighter,
onTapLink: onTapLink,
imageDirectory: imageDirectory,
+ extensionSet: extensionSet,
+ imageBuilder: imageBuilder,
+ checkboxBuilder: checkboxBuilder,
);
+ /// See [ScrollView.shrinkWrap]
+ final bool shrinkWrap;
+
@override
Widget build(BuildContext context, List<Widget> children) {
- if (children.length == 1) return children.single;
+ if (children.length == 1 && !shrinkWrap) return children.single;
return Column(
+ mainAxisSize: shrinkWrap ? MainAxisSize.min : MainAxisSize.max,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: children,
);
@@ -190,13 +225,13 @@
/// A scrolling widget that parses and displays Markdown.
///
-/// Supports all standard Markdown from the original
-/// [Markdown specification](https://daringfireball.net/projects/markdown/).
+/// Supports all GitHub Flavored Markdown from the
+/// [specification](https://github.github.com/gfm/).
///
/// See also:
///
/// * [MarkdownBody], which is a non-scrolling container of Markdown.
-/// * <https://daringfireball.net/projects/markdown/>
+/// * <https://github.github.com/gfm/>
class Markdown extends MarkdownWidget {
/// Creates a scrolling widget that parses and displays Markdown.
const Markdown({
@@ -205,8 +240,13 @@
MarkdownStyleSheet styleSheet,
SyntaxHighlighter syntaxHighlighter,
MarkdownTapLinkCallback onTapLink,
- Directory imageDirectory,
+ String imageDirectory,
+ md.ExtensionSet extensionSet,
+ MarkdownImageBuilder imageBuilder,
+ MarkdownCheckboxBuilder checkboxBuilder,
this.padding: const EdgeInsets.all(16.0),
+ this.physics,
+ this.shrinkWrap: false,
}) : super(
key: key,
data: data,
@@ -214,13 +254,50 @@
syntaxHighlighter: syntaxHighlighter,
onTapLink: onTapLink,
imageDirectory: imageDirectory,
+ extensionSet: extensionSet,
+ imageBuilder: imageBuilder,
+ checkboxBuilder: checkboxBuilder,
);
/// The amount of space by which to inset the children.
final EdgeInsets padding;
+ /// How the scroll view should respond to user input.
+ ///
+ /// See also: [ScrollView.physics]
+ final ScrollPhysics physics;
+
+ /// Whether the extent of the scroll view in the scroll direction should be
+ /// determined by the contents being viewed.
+ ///
+ /// See also: [ScrollView.shrinkWrap]
+ final bool shrinkWrap;
+
@override
Widget build(BuildContext context, List<Widget> children) {
- return ListView(padding: padding, children: children);
+ return ListView(
+ padding: padding,
+ physics: physics,
+ shrinkWrap: shrinkWrap,
+ children: children,
+ );
+ }
+}
+
+/// Parse [task list items](https://github.github.com/gfm/#task-list-items-extension-).
+class TaskListSyntax extends md.InlineSyntax {
+ // FIXME: incorrect
+ static final String _pattern = r'^ *\[([ xX])\] +';
+
+ TaskListSyntax() : super(_pattern);
+
+ @override
+ bool onMatch(md.InlineParser parser, Match match) {
+ md.Element el = md.Element.withTag('input');
+ el.attributes['type'] = 'checkbox';
+ el.attributes['disabled'] = 'true';
+ el.attributes['checked'] = '${match[1].trim().isNotEmpty}';
+ parser.addNode(el);
+ return true;
}
}
diff --git a/packages/flutter_markdown/pubspec.lock b/packages/flutter_markdown/pubspec.lock
index ddf7393..7758c46 100644
--- a/packages/flutter_markdown/pubspec.lock
+++ b/packages/flutter_markdown/pubspec.lock
@@ -173,7 +173,7 @@
source: hosted
version: "0.12.5"
meta:
- dependency: "direct main"
+ dependency: transitive
description:
name: meta
url: "https://pub.flutter-io.cn"
@@ -222,7 +222,7 @@
source: hosted
version: "1.0.10"
path:
- dependency: "direct main"
+ dependency: transitive
description:
name: path
url: "https://pub.flutter-io.cn"
@@ -332,7 +332,7 @@
source: hosted
version: "2.0.0"
string_scanner:
- dependency: "direct main"
+ dependency: transitive
description:
name: string_scanner
url: "https://pub.flutter-io.cn"
diff --git a/packages/flutter_markdown/pubspec.yaml b/packages/flutter_markdown/pubspec.yaml
index 8509a73..4f1e7fb 100644
--- a/packages/flutter_markdown/pubspec.yaml
+++ b/packages/flutter_markdown/pubspec.yaml
@@ -8,9 +8,6 @@
flutter:
sdk: flutter
markdown: ^2.0.0
- meta: ^1.0.5
- string_scanner: ^1.0.0
- path: ^1.5.1
dev_dependencies:
flutter_test:
diff --git a/packages/flutter_markdown/test/flutter_markdown_test.dart b/packages/flutter_markdown/test/flutter_markdown_test.dart
index e33573e..ee007e5 100644
--- a/packages/flutter_markdown/test/flutter_markdown_test.dart
+++ b/packages/flutter_markdown/test/flutter_markdown_test.dart
@@ -44,6 +44,15 @@
_expectTextStrings(widgets, <String>['strikethrough']);
});
+ testWidgets('Line break', (WidgetTester tester) async {
+ await tester.pumpWidget(_boilerplate(const MarkdownBody(data: 'line 1 \nline 2')));
+
+ final Iterable<Widget> widgets = tester.allWidgets;
+ _expectWidgetTypes(
+ widgets, <Type>[Directionality, MarkdownBody, Column, Wrap, RichText]);
+ _expectTextStrings(widgets, <String>['line 1\nline 2']);
+ });
+
testWidgets('Empty string', (WidgetTester tester) async {
await tester.pumpWidget(_boilerplate(const MarkdownBody(data: '')));
@@ -83,6 +92,18 @@
]);
});
+ testWidgets('Task list', (WidgetTester tester) async {
+ await tester.pumpWidget(
+ _boilerplate(const MarkdownBody(data: '- [x] Item 1\n- [ ] Item 2')),
+ );
+
+ final Iterable<Widget> widgets = tester.allWidgets;
+
+ // \ue834 -> Icons.check_box
+ // \ue835 -> Icons.check_box_outline_blank
+ _expectTextStrings(widgets, <String>['\ue834', 'Item 1', '\ue835', 'Item 2']);
+ });
+
testWidgets('Horizontal Rule', (WidgetTester tester) async {
await tester.pumpWidget(_boilerplate(const MarkdownBody(data: '-----')));
@@ -348,6 +369,59 @@
});
});
+ group('Tables', () {
+ testWidgets('should show properly', (WidgetTester tester) async {
+ const String data = '|Header 1|Header 2|\n|-----|-----|\n|Col 1|Col 2|';
+ await tester.pumpWidget(_boilerplate(const MarkdownBody(data: data)));
+
+ final Iterable<Widget> widgets = tester.allWidgets;
+ _expectTextStrings(
+ widgets, <String>['Header 1', 'Header 2', 'Col 1', 'Col 2']);
+ });
+
+ testWidgets('work without the outer pipes', (WidgetTester tester) async {
+ const String data = 'Header 1|Header 2\n-----|-----\nCol 1|Col 2';
+ await tester.pumpWidget(_boilerplate(const MarkdownBody(data: data)));
+
+ final Iterable<Widget> widgets = tester.allWidgets;
+ _expectTextStrings(
+ widgets, <String>['Header 1', 'Header 2', 'Col 1', 'Col 2']);
+ });
+
+ testWidgets('should work with alignments', (WidgetTester tester) async {
+ const String data = '|Header 1|Header 2|\n|:----:|----:|\n|Col 1|Col 2|';
+ await tester.pumpWidget(_boilerplate(const MarkdownBody(data: data)));
+
+ final Iterable<Widget> widgets = tester.allWidgets;
+ final DefaultTextStyle style = widgets.firstWhere((Widget widget) => widget is DefaultTextStyle);
+ final DefaultTextStyle style2 = widgets.lastWhere((Widget widget) => widget is DefaultTextStyle);
+
+ expect(style.textAlign, TextAlign.center);
+ expect(style2.textAlign, TextAlign.right);
+ });
+
+ testWidgets('should work with styling', (WidgetTester tester) async {
+ const String data = '|Header|\n|----|\n|*italic*|';
+ await tester.pumpWidget(_boilerplate(MarkdownBody(data: data)));
+
+ final Iterable<Widget> widgets = tester.allWidgets;
+ final RichText richText = widgets.lastWhere((Widget widget) => widget is RichText);
+
+ _expectTextStrings(widgets, <String>['Header', 'italic']);
+ expect(richText.text.style.fontStyle, FontStyle.italic);
+ });
+
+ testWidgets('should work next to other tables', (WidgetTester tester) async {
+ const String data = '|first header|\n|----|\n|first col|\n\n'
+ '|second header|\n|----|\n|second col|';
+ await tester.pumpWidget(_boilerplate(const MarkdownBody(data: data)));
+
+ final Iterable<Widget> tables = tester.allWidgets.where((Widget widget) => widget is Table);
+
+ expect(tables.length, 2);
+ });
+ });
+
group('uri data scheme', () {
testWidgets('should work with image in uri data scheme', (WidgetTester tester) async {
const String imageData = '';