Initial support for links in markdown
This commit is contained in:
parent
3d8176ed16
commit
800e2558ba
@ -15,6 +15,9 @@ Style text as _italic_, __bold__, or `inline code`.
|
|||||||
- To better clarify
|
- To better clarify
|
||||||
- Your points
|
- Your points
|
||||||
|
|
||||||
|
## Links
|
||||||
|
You can use [hyperlinks](hyperlink) in markdown
|
||||||
|
|
||||||
## Code blocks
|
## Code blocks
|
||||||
Formatted Dart code looks really pretty too. This is an example of how to create your own Markdown widget:
|
Formatted Dart code looks really pretty too. This is an example of how to create your own Markdown widget:
|
||||||
|
|
||||||
|
@ -25,22 +25,26 @@ class Markdown extends MarkdownRaw {
|
|||||||
Markdown({
|
Markdown({
|
||||||
String data,
|
String data,
|
||||||
SyntaxHighlighter syntaxHighlighter,
|
SyntaxHighlighter syntaxHighlighter,
|
||||||
MarkdownStyle markdownStyle
|
MarkdownStyle markdownStyle,
|
||||||
|
MarkdownLinkCallback onTapLink
|
||||||
}) : super(
|
}) : super(
|
||||||
data: data,
|
data: data,
|
||||||
syntaxHighlighter: syntaxHighlighter,
|
syntaxHighlighter: syntaxHighlighter,
|
||||||
markdownStyle: markdownStyle
|
markdownStyle: markdownStyle,
|
||||||
|
onTapLink: onTapLink
|
||||||
);
|
);
|
||||||
|
|
||||||
MarkdownBody createMarkdownBody({
|
MarkdownBody createMarkdownBody({
|
||||||
String data,
|
String data,
|
||||||
MarkdownStyle markdownStyle,
|
MarkdownStyle markdownStyle,
|
||||||
SyntaxHighlighter syntaxHighlighter
|
SyntaxHighlighter syntaxHighlighter,
|
||||||
|
MarkdownLinkCallback onTapLink
|
||||||
}) {
|
}) {
|
||||||
return new MarkdownBody(
|
return new MarkdownBody(
|
||||||
data: data,
|
data: data,
|
||||||
markdownStyle: markdownStyle,
|
markdownStyle: markdownStyle,
|
||||||
syntaxHighlighter: syntaxHighlighter
|
syntaxHighlighter: syntaxHighlighter,
|
||||||
|
onTapLink: onTapLink
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -71,11 +75,13 @@ class MarkdownBody extends MarkdownBodyRaw {
|
|||||||
MarkdownBody({
|
MarkdownBody({
|
||||||
String data,
|
String data,
|
||||||
SyntaxHighlighter syntaxHighlighter,
|
SyntaxHighlighter syntaxHighlighter,
|
||||||
MarkdownStyle markdownStyle
|
MarkdownStyle markdownStyle,
|
||||||
|
MarkdownLinkCallback onTapLink
|
||||||
}) : super(
|
}) : super(
|
||||||
data: data,
|
data: data,
|
||||||
syntaxHighlighter: syntaxHighlighter,
|
syntaxHighlighter: syntaxHighlighter,
|
||||||
markdownStyle: markdownStyle
|
markdownStyle: markdownStyle,
|
||||||
|
onTapLink: onTapLink
|
||||||
);
|
);
|
||||||
|
|
||||||
MarkdownStyle createDefaultStyle(BuildContext context) {
|
MarkdownStyle createDefaultStyle(BuildContext context) {
|
||||||
|
@ -4,8 +4,11 @@
|
|||||||
|
|
||||||
import 'package:markdown/markdown.dart' as md;
|
import 'package:markdown/markdown.dart' as md;
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
|
import 'package:flutter/gestures.dart';
|
||||||
import 'markdown_style_raw.dart';
|
import 'markdown_style_raw.dart';
|
||||||
|
|
||||||
|
typedef void MarkdownLinkCallback(String href);
|
||||||
|
|
||||||
|
|
||||||
/// A [Widget] that renders markdown formatted text. It supports all standard
|
/// A [Widget] that renders markdown formatted text. It supports all standard
|
||||||
/// markdowns from the original markdown specification found here:
|
/// markdowns from the original markdown specification found here:
|
||||||
@ -26,7 +29,8 @@ class MarkdownRaw extends StatelessComponent {
|
|||||||
this.data,
|
this.data,
|
||||||
this.markdownStyle,
|
this.markdownStyle,
|
||||||
this.syntaxHighlighter,
|
this.syntaxHighlighter,
|
||||||
this.padding: const EdgeDims.all(16.0)
|
this.padding: const EdgeDims.all(16.0),
|
||||||
|
this.onTapLink
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Markdown styled text
|
/// Markdown styled text
|
||||||
@ -41,6 +45,9 @@ class MarkdownRaw extends StatelessComponent {
|
|||||||
/// Padding used
|
/// Padding used
|
||||||
final EdgeDims padding;
|
final EdgeDims padding;
|
||||||
|
|
||||||
|
/// Callback when a link is tapped
|
||||||
|
final MarkdownLinkCallback onTapLink;
|
||||||
|
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return new ScrollableViewport(
|
return new ScrollableViewport(
|
||||||
child: new Padding(
|
child: new Padding(
|
||||||
@ -48,7 +55,8 @@ class MarkdownRaw extends StatelessComponent {
|
|||||||
child: createMarkdownBody(
|
child: createMarkdownBody(
|
||||||
data: data,
|
data: data,
|
||||||
markdownStyle: markdownStyle,
|
markdownStyle: markdownStyle,
|
||||||
syntaxHighlighter: syntaxHighlighter
|
syntaxHighlighter: syntaxHighlighter,
|
||||||
|
onTapLink: onTapLink
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@ -57,12 +65,14 @@ class MarkdownRaw extends StatelessComponent {
|
|||||||
MarkdownBodyRaw createMarkdownBody({
|
MarkdownBodyRaw createMarkdownBody({
|
||||||
String data,
|
String data,
|
||||||
MarkdownStyleRaw markdownStyle,
|
MarkdownStyleRaw markdownStyle,
|
||||||
SyntaxHighlighter syntaxHighlighter
|
SyntaxHighlighter syntaxHighlighter,
|
||||||
|
MarkdownLinkCallback onTapLink
|
||||||
}) {
|
}) {
|
||||||
return new MarkdownBodyRaw(
|
return new MarkdownBodyRaw(
|
||||||
data: data,
|
data: data,
|
||||||
markdownStyle: markdownStyle,
|
markdownStyle: markdownStyle,
|
||||||
syntaxHighlighter: syntaxHighlighter
|
syntaxHighlighter: syntaxHighlighter,
|
||||||
|
onTapLink: onTapLink
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -94,7 +104,8 @@ class MarkdownBodyRaw extends StatefulComponent {
|
|||||||
MarkdownBodyRaw({
|
MarkdownBodyRaw({
|
||||||
this.data,
|
this.data,
|
||||||
this.markdownStyle,
|
this.markdownStyle,
|
||||||
this.syntaxHighlighter
|
this.syntaxHighlighter,
|
||||||
|
this.onTapLink
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Markdown styled text
|
/// Markdown styled text
|
||||||
@ -106,6 +117,9 @@ class MarkdownBodyRaw extends StatefulComponent {
|
|||||||
/// The syntax highlighter used to color text in code blocks
|
/// The syntax highlighter used to color text in code blocks
|
||||||
final SyntaxHighlighter syntaxHighlighter;
|
final SyntaxHighlighter syntaxHighlighter;
|
||||||
|
|
||||||
|
/// Callback when a link is tapped
|
||||||
|
final MarkdownLinkCallback onTapLink;
|
||||||
|
|
||||||
_MarkdownBodyRawState createState() => new _MarkdownBodyRawState();
|
_MarkdownBodyRawState createState() => new _MarkdownBodyRawState();
|
||||||
|
|
||||||
MarkdownStyleRaw createDefaultStyle(BuildContext context) => null;
|
MarkdownStyleRaw createDefaultStyle(BuildContext context) => null;
|
||||||
@ -119,10 +133,23 @@ class _MarkdownBodyRawState extends State<MarkdownBodyRaw> {
|
|||||||
MarkdownStyleRaw markdownStyle = config.markdownStyle ?? config.createDefaultStyle(context);
|
MarkdownStyleRaw markdownStyle = config.markdownStyle ?? config.createDefaultStyle(context);
|
||||||
SyntaxHighlighter syntaxHighlighter = config.syntaxHighlighter ?? new _DefaultSyntaxHighlighter(markdownStyle.code);
|
SyntaxHighlighter syntaxHighlighter = config.syntaxHighlighter ?? new _DefaultSyntaxHighlighter(markdownStyle.code);
|
||||||
|
|
||||||
_cachedBlocks = _blocksFromMarkup(config.data, markdownStyle, syntaxHighlighter);
|
_linkHandler = new _LinkHandler(config.onTapLink);
|
||||||
|
|
||||||
|
// TODO: This can be optimized by doing the split and removing \r at the same time
|
||||||
|
List<String> lines = config.data.replaceAll('\r\n', '\n').split('\n');
|
||||||
|
md.Document document = new md.Document();
|
||||||
|
|
||||||
|
_Renderer renderer = new _Renderer();
|
||||||
|
_cachedBlocks = renderer.render(document.parseLines(lines), markdownStyle, syntaxHighlighter, _linkHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
void dispose() {
|
||||||
|
_linkHandler.dispose();
|
||||||
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
List<_Block> _cachedBlocks;
|
List<_Block> _cachedBlocks;
|
||||||
|
_LinkHandler _linkHandler;
|
||||||
|
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
List<Widget> blocks = <Widget>[];
|
List<Widget> blocks = <Widget>[];
|
||||||
@ -137,23 +164,15 @@ class _MarkdownBodyRawState extends State<MarkdownBodyRaw> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
List<_Block> _blocksFromMarkup(String data, MarkdownStyleRaw markdownStyle, SyntaxHighlighter syntaxHighlighter) {
|
|
||||||
// TODO: This can be optimized by doing the split and removing \r at the same time
|
|
||||||
List<String> lines = data.replaceAll('\r\n', '\n').split('\n');
|
|
||||||
md.Document document = new md.Document();
|
|
||||||
|
|
||||||
_Renderer renderer = new _Renderer();
|
|
||||||
return renderer.render(document.parseLines(lines), markdownStyle, syntaxHighlighter);
|
|
||||||
}
|
|
||||||
|
|
||||||
class _Renderer implements md.NodeVisitor {
|
class _Renderer implements md.NodeVisitor {
|
||||||
List<_Block> render(List<md.Node> nodes, MarkdownStyleRaw markdownStyle, SyntaxHighlighter syntaxHighlighter) {
|
List<_Block> render(List<md.Node> nodes, MarkdownStyleRaw markdownStyle, SyntaxHighlighter syntaxHighlighter, _LinkHandler linkHandler) {
|
||||||
assert(markdownStyle != null);
|
assert(markdownStyle != null);
|
||||||
|
|
||||||
_blocks = <_Block>[];
|
_blocks = <_Block>[];
|
||||||
_listIndents = <String>[];
|
_listIndents = <String>[];
|
||||||
_markdownStyle = markdownStyle;
|
_markdownStyle = markdownStyle;
|
||||||
_syntaxHighlighter = syntaxHighlighter;
|
_syntaxHighlighter = syntaxHighlighter;
|
||||||
|
_linkHandler = linkHandler;
|
||||||
|
|
||||||
for (final md.Node node in nodes) {
|
for (final md.Node node in nodes) {
|
||||||
node.accept(this);
|
node.accept(this);
|
||||||
@ -166,6 +185,7 @@ class _Renderer implements md.NodeVisitor {
|
|||||||
List<String> _listIndents;
|
List<String> _listIndents;
|
||||||
MarkdownStyleRaw _markdownStyle;
|
MarkdownStyleRaw _markdownStyle;
|
||||||
SyntaxHighlighter _syntaxHighlighter;
|
SyntaxHighlighter _syntaxHighlighter;
|
||||||
|
_LinkHandler _linkHandler;
|
||||||
|
|
||||||
void visitText(md.Text text) {
|
void visitText(md.Text text) {
|
||||||
_MarkdownNodeList topList = _currentBlock.stack.last;
|
_MarkdownNodeList topList = _currentBlock.stack.last;
|
||||||
@ -191,8 +211,13 @@ class _Renderer implements md.NodeVisitor {
|
|||||||
_Block newBlock = new _Block(element.tag, element.attributes, _markdownStyle, new List<String>.from(_listIndents), blockList.length);
|
_Block newBlock = new _Block(element.tag, element.attributes, _markdownStyle, new List<String>.from(_listIndents), blockList.length);
|
||||||
blockList.add(newBlock);
|
blockList.add(newBlock);
|
||||||
} else {
|
} else {
|
||||||
|
_LinkInfo linkInfo = null;
|
||||||
|
if (element.tag == 'a') {
|
||||||
|
linkInfo = _linkHandler.createLinkInfo(element.attributes['href']);
|
||||||
|
}
|
||||||
|
|
||||||
TextStyle style = _markdownStyle.styles[element.tag] ?? new TextStyle();
|
TextStyle style = _markdownStyle.styles[element.tag] ?? new TextStyle();
|
||||||
List<_MarkdownNode> styleElement = <_MarkdownNode>[new _MarkdownNodeTextStyle(style)];
|
List<_MarkdownNode> styleElement = <_MarkdownNode>[new _MarkdownNodeTextStyle(style, linkInfo)];
|
||||||
_currentBlock.stack.add(new _MarkdownNodeList(styleElement));
|
_currentBlock.stack.add(new _MarkdownNodeList(styleElement));
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
@ -260,8 +285,9 @@ class _MarkdownNodeList extends _MarkdownNode {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _MarkdownNodeTextStyle extends _MarkdownNode {
|
class _MarkdownNodeTextStyle extends _MarkdownNode {
|
||||||
_MarkdownNodeTextStyle(this.style);
|
_MarkdownNodeTextStyle(this.style, [this.linkInfo = null]);
|
||||||
TextStyle style;
|
TextStyle style;
|
||||||
|
_LinkInfo linkInfo;
|
||||||
}
|
}
|
||||||
|
|
||||||
class _MarkdownNodeString extends _MarkdownNode {
|
class _MarkdownNodeString extends _MarkdownNode {
|
||||||
@ -325,7 +351,8 @@ class _Block {
|
|||||||
children: subWidgets
|
children: subWidgets
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
contents = new RichText(text: _stackToTextSpan(new _MarkdownNodeList(stack)));
|
TextSpan span = _stackToTextSpan(new _MarkdownNodeList(stack));
|
||||||
|
contents = new RichText(text: span);
|
||||||
|
|
||||||
if (listIndents.length > 0) {
|
if (listIndents.length > 0) {
|
||||||
Widget bullet;
|
Widget bullet;
|
||||||
@ -384,13 +411,23 @@ class _Block {
|
|||||||
if (stack is _MarkdownNodeList) {
|
if (stack is _MarkdownNodeList) {
|
||||||
List<_MarkdownNode> list = stack.list;
|
List<_MarkdownNode> list = stack.list;
|
||||||
_MarkdownNodeTextStyle styleNode = list[0];
|
_MarkdownNodeTextStyle styleNode = list[0];
|
||||||
|
_LinkInfo linkInfo = styleNode.linkInfo;
|
||||||
TextStyle style = styleNode.style;
|
TextStyle style = styleNode.style;
|
||||||
|
|
||||||
List<TextSpan> children = <TextSpan>[];
|
List<TextSpan> children = <TextSpan>[];
|
||||||
for (int i = 1; i < list.length; i++) {
|
for (int i = 1; i < list.length; i++) {
|
||||||
children.add(_stackToTextSpan(list[i]));
|
children.add(_stackToTextSpan(list[i]));
|
||||||
}
|
}
|
||||||
return new TextSpan(style: style, children: children);
|
|
||||||
|
String text = null;
|
||||||
|
if (children.length == 1 && _isPlainText(children[0])) {
|
||||||
|
text = children[0].text;
|
||||||
|
children = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
TapGestureRecognizer recognizer = linkInfo?.recognizer;
|
||||||
|
|
||||||
|
return new TextSpan(style: style, children: children, recognizer: recognizer, text: text);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (stack is _MarkdownNodeString) {
|
if (stack is _MarkdownNodeString) {
|
||||||
@ -400,6 +437,10 @@ class _Block {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool _isPlainText(TextSpan span) {
|
||||||
|
return (span.text != null && span.style == null && span.recognizer == null && span.children == null);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildImage(BuildContext context, String src) {
|
Widget _buildImage(BuildContext context, String src) {
|
||||||
List<String> parts = src.split('#');
|
List<String> parts = src.split('#');
|
||||||
if (parts.length == 0) return new Container();
|
if (parts.length == 0) return new Container();
|
||||||
@ -419,6 +460,39 @@ class _Block {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _LinkInfo {
|
||||||
|
_LinkInfo(this.href, this.recognizer);
|
||||||
|
|
||||||
|
final String href;
|
||||||
|
final TapGestureRecognizer recognizer;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LinkHandler {
|
||||||
|
_LinkHandler(this.onTapLink);
|
||||||
|
|
||||||
|
List<_LinkInfo> links = <_LinkInfo>[];
|
||||||
|
MarkdownLinkCallback onTapLink;
|
||||||
|
|
||||||
|
_LinkInfo createLinkInfo(String href) {
|
||||||
|
TapGestureRecognizer recognizer = new TapGestureRecognizer();
|
||||||
|
recognizer.onTap = () {
|
||||||
|
if (onTapLink != null)
|
||||||
|
onTapLink(href);
|
||||||
|
};
|
||||||
|
|
||||||
|
_LinkInfo linkInfo = new _LinkInfo(href, recognizer);
|
||||||
|
links.add(linkInfo);
|
||||||
|
|
||||||
|
return linkInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
void dispose() {
|
||||||
|
for (_LinkInfo linkInfo in links) {
|
||||||
|
linkInfo.recognizer.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
abstract class SyntaxHighlighter {
|
abstract class SyntaxHighlighter {
|
||||||
TextSpan format(String source);
|
TextSpan format(String source);
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter_markdown/flutter_markdown.dart';
|
import 'package:flutter_markdown/flutter_markdown.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:flutter/gestures.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:test/test.dart';
|
import 'package:test/test.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
@ -9,13 +10,9 @@ void main() {
|
|||||||
testWidgets((WidgetTester tester) {
|
testWidgets((WidgetTester tester) {
|
||||||
tester.pumpWidget(new MarkdownBody(data: "Hello"));
|
tester.pumpWidget(new MarkdownBody(data: "Hello"));
|
||||||
|
|
||||||
Element textElement = tester.findElement((Element element) => element.widget is RichText);
|
|
||||||
RichText textWidget = textElement.widget;
|
|
||||||
TextSpan textSpan = textWidget.text;
|
|
||||||
|
|
||||||
List<Element> elements = _listElements(tester);
|
List<Element> elements = _listElements(tester);
|
||||||
_expectWidgetTypes(elements, <Type>[MarkdownBody, Column, Container, Padding, RichText]);
|
_expectWidgetTypes(elements, <Type>[MarkdownBody, Column, Container, Padding, RichText]);
|
||||||
expect(textSpan.children[0].text, equals("Hello"));
|
_expectTextStrings(elements, <String>["Hello"]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -23,13 +20,9 @@ void main() {
|
|||||||
testWidgets((WidgetTester tester) {
|
testWidgets((WidgetTester tester) {
|
||||||
tester.pumpWidget(new MarkdownBody(data: "# Header"));
|
tester.pumpWidget(new MarkdownBody(data: "# Header"));
|
||||||
|
|
||||||
Element textElement = tester.findElement((Element element) => element.widget is RichText);
|
|
||||||
RichText textWidget = textElement.widget;
|
|
||||||
TextSpan textSpan = textWidget.text;
|
|
||||||
|
|
||||||
List<Element> elements = _listElements(tester);
|
List<Element> elements = _listElements(tester);
|
||||||
_expectWidgetTypes(elements, <Type>[MarkdownBody, Column, Container, Padding, RichText]);
|
_expectWidgetTypes(elements, <Type>[MarkdownBody, Column, Container, Padding, RichText]);
|
||||||
expect(textSpan.children[0].text, equals("Header"));
|
_expectTextStrings(elements, <String>["Header"]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -79,7 +72,6 @@ void main() {
|
|||||||
tester.pumpWidget(new Markdown(data: ""));
|
tester.pumpWidget(new Markdown(data: ""));
|
||||||
|
|
||||||
List<Element> elements = _listElements(tester);
|
List<Element> elements = _listElements(tester);
|
||||||
for (Element element in elements) print("e: $element");
|
|
||||||
_expectWidgetTypes(elements, <Type>[
|
_expectWidgetTypes(elements, <Type>[
|
||||||
Markdown,
|
Markdown,
|
||||||
ScrollableViewport,
|
ScrollableViewport,
|
||||||
@ -90,6 +82,18 @@ void main() {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("Links", () {
|
||||||
|
testWidgets((WidgetTester tester) {
|
||||||
|
tester.pumpWidget(new Markdown(data: "[Link Text](href)"));
|
||||||
|
|
||||||
|
Element textElement = tester.findElement((Element element) => element.widget is RichText);
|
||||||
|
RichText textWidget = textElement.widget;
|
||||||
|
TextSpan span = textWidget.text;
|
||||||
|
|
||||||
|
expect(span.children[0].recognizer.runtimeType, equals(TapGestureRecognizer));
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
List<Element> _listElements(WidgetTester tester) {
|
List<Element> _listElements(WidgetTester tester) {
|
||||||
|
Loading…
x
Reference in New Issue
Block a user