From 800e2558badb0acdbffa27e5bf81b84d11756d51 Mon Sep 17 00:00:00 2001 From: Viktor Lidholt <viktorl@google.com> Date: Thu, 10 Mar 2016 13:51:15 -0800 Subject: [PATCH] Initial support for links in markdown --- packages/flutter_markdown/example/demo.dart | 3 + .../flutter_markdown/lib/src/markdown.dart | 18 ++- .../lib/src/markdown_raw.dart | 114 +++++++++++++++--- .../test/flutter_markdown_test.dart | 26 ++-- 4 files changed, 124 insertions(+), 37 deletions(-) diff --git a/packages/flutter_markdown/example/demo.dart b/packages/flutter_markdown/example/demo.dart index 14bce27408..64eb2ac899 100644 --- a/packages/flutter_markdown/example/demo.dart +++ b/packages/flutter_markdown/example/demo.dart @@ -15,6 +15,9 @@ Style text as _italic_, __bold__, or `inline code`. - To better clarify - Your points +## Links +You can use [hyperlinks](hyperlink) in markdown + ## Code blocks Formatted Dart code looks really pretty too. This is an example of how to create your own Markdown widget: diff --git a/packages/flutter_markdown/lib/src/markdown.dart b/packages/flutter_markdown/lib/src/markdown.dart index 3b437b1241..aa8962729b 100644 --- a/packages/flutter_markdown/lib/src/markdown.dart +++ b/packages/flutter_markdown/lib/src/markdown.dart @@ -25,22 +25,26 @@ class Markdown extends MarkdownRaw { Markdown({ String data, SyntaxHighlighter syntaxHighlighter, - MarkdownStyle markdownStyle + MarkdownStyle markdownStyle, + MarkdownLinkCallback onTapLink }) : super( data: data, syntaxHighlighter: syntaxHighlighter, - markdownStyle: markdownStyle + markdownStyle: markdownStyle, + onTapLink: onTapLink ); MarkdownBody createMarkdownBody({ String data, MarkdownStyle markdownStyle, - SyntaxHighlighter syntaxHighlighter + SyntaxHighlighter syntaxHighlighter, + MarkdownLinkCallback onTapLink }) { return new MarkdownBody( data: data, markdownStyle: markdownStyle, - syntaxHighlighter: syntaxHighlighter + syntaxHighlighter: syntaxHighlighter, + onTapLink: onTapLink ); } } @@ -71,11 +75,13 @@ class MarkdownBody extends MarkdownBodyRaw { MarkdownBody({ String data, SyntaxHighlighter syntaxHighlighter, - MarkdownStyle markdownStyle + MarkdownStyle markdownStyle, + MarkdownLinkCallback onTapLink }) : super( data: data, syntaxHighlighter: syntaxHighlighter, - markdownStyle: markdownStyle + markdownStyle: markdownStyle, + onTapLink: onTapLink ); MarkdownStyle createDefaultStyle(BuildContext context) { diff --git a/packages/flutter_markdown/lib/src/markdown_raw.dart b/packages/flutter_markdown/lib/src/markdown_raw.dart index 043e3e17d3..13fc714546 100644 --- a/packages/flutter_markdown/lib/src/markdown_raw.dart +++ b/packages/flutter_markdown/lib/src/markdown_raw.dart @@ -4,8 +4,11 @@ import 'package:markdown/markdown.dart' as md; import 'package:flutter/widgets.dart'; +import 'package:flutter/gestures.dart'; import 'markdown_style_raw.dart'; +typedef void MarkdownLinkCallback(String href); + /// A [Widget] that renders markdown formatted text. It supports all standard /// markdowns from the original markdown specification found here: @@ -26,7 +29,8 @@ class MarkdownRaw extends StatelessComponent { this.data, this.markdownStyle, this.syntaxHighlighter, - this.padding: const EdgeDims.all(16.0) + this.padding: const EdgeDims.all(16.0), + this.onTapLink }); /// Markdown styled text @@ -41,6 +45,9 @@ class MarkdownRaw extends StatelessComponent { /// Padding used final EdgeDims padding; + /// Callback when a link is tapped + final MarkdownLinkCallback onTapLink; + Widget build(BuildContext context) { return new ScrollableViewport( child: new Padding( @@ -48,7 +55,8 @@ class MarkdownRaw extends StatelessComponent { child: createMarkdownBody( data: data, markdownStyle: markdownStyle, - syntaxHighlighter: syntaxHighlighter + syntaxHighlighter: syntaxHighlighter, + onTapLink: onTapLink ) ) ); @@ -57,12 +65,14 @@ class MarkdownRaw extends StatelessComponent { MarkdownBodyRaw createMarkdownBody({ String data, MarkdownStyleRaw markdownStyle, - SyntaxHighlighter syntaxHighlighter + SyntaxHighlighter syntaxHighlighter, + MarkdownLinkCallback onTapLink }) { return new MarkdownBodyRaw( data: data, markdownStyle: markdownStyle, - syntaxHighlighter: syntaxHighlighter + syntaxHighlighter: syntaxHighlighter, + onTapLink: onTapLink ); } } @@ -94,7 +104,8 @@ class MarkdownBodyRaw extends StatefulComponent { MarkdownBodyRaw({ this.data, this.markdownStyle, - this.syntaxHighlighter + this.syntaxHighlighter, + this.onTapLink }); /// Markdown styled text @@ -106,6 +117,9 @@ class MarkdownBodyRaw extends StatefulComponent { /// The syntax highlighter used to color text in code blocks final SyntaxHighlighter syntaxHighlighter; + /// Callback when a link is tapped + final MarkdownLinkCallback onTapLink; + _MarkdownBodyRawState createState() => new _MarkdownBodyRawState(); MarkdownStyleRaw createDefaultStyle(BuildContext context) => null; @@ -119,10 +133,23 @@ class _MarkdownBodyRawState extends State<MarkdownBodyRaw> { MarkdownStyleRaw markdownStyle = config.markdownStyle ?? config.createDefaultStyle(context); 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; + _LinkHandler _linkHandler; Widget build(BuildContext context) { 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 { - 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); _blocks = <_Block>[]; _listIndents = <String>[]; _markdownStyle = markdownStyle; _syntaxHighlighter = syntaxHighlighter; + _linkHandler = linkHandler; for (final md.Node node in nodes) { node.accept(this); @@ -166,6 +185,7 @@ class _Renderer implements md.NodeVisitor { List<String> _listIndents; MarkdownStyleRaw _markdownStyle; SyntaxHighlighter _syntaxHighlighter; + _LinkHandler _linkHandler; void visitText(md.Text text) { _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); blockList.add(newBlock); } else { + _LinkInfo linkInfo = null; + if (element.tag == 'a') { + linkInfo = _linkHandler.createLinkInfo(element.attributes['href']); + } + 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)); } return true; @@ -260,8 +285,9 @@ class _MarkdownNodeList extends _MarkdownNode { } class _MarkdownNodeTextStyle extends _MarkdownNode { - _MarkdownNodeTextStyle(this.style); + _MarkdownNodeTextStyle(this.style, [this.linkInfo = null]); TextStyle style; + _LinkInfo linkInfo; } class _MarkdownNodeString extends _MarkdownNode { @@ -325,7 +351,8 @@ class _Block { children: subWidgets ); } else { - contents = new RichText(text: _stackToTextSpan(new _MarkdownNodeList(stack))); + TextSpan span = _stackToTextSpan(new _MarkdownNodeList(stack)); + contents = new RichText(text: span); if (listIndents.length > 0) { Widget bullet; @@ -384,13 +411,23 @@ class _Block { if (stack is _MarkdownNodeList) { List<_MarkdownNode> list = stack.list; _MarkdownNodeTextStyle styleNode = list[0]; + _LinkInfo linkInfo = styleNode.linkInfo; TextStyle style = styleNode.style; List<TextSpan> children = <TextSpan>[]; for (int i = 1; i < list.length; 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) { @@ -400,6 +437,10 @@ class _Block { 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) { List<String> parts = src.split('#'); 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 { TextSpan format(String source); } diff --git a/packages/flutter_markdown/test/flutter_markdown_test.dart b/packages/flutter_markdown/test/flutter_markdown_test.dart index f738572fa8..72bc37f5a4 100644 --- a/packages/flutter_markdown/test/flutter_markdown_test.dart +++ b/packages/flutter_markdown/test/flutter_markdown_test.dart @@ -1,5 +1,6 @@ import 'package:flutter_markdown/flutter_markdown.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter/gestures.dart'; import 'package:flutter/widgets.dart'; import 'package:test/test.dart'; import 'package:flutter/material.dart'; @@ -9,13 +10,9 @@ void main() { testWidgets((WidgetTester tester) { 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); _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) { 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); _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: "")); List<Element> elements = _listElements(tester); - for (Element element in elements) print("e: $element"); _expectWidgetTypes(elements, <Type>[ Markdown, 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) { -- 2.21.0