diff --git a/packages/flutter_markdown/README.md b/packages/flutter_markdown/README.md new file mode 100644 index 0000000000..db83e6b73f --- /dev/null +++ b/packages/flutter_markdown/README.md @@ -0,0 +1,22 @@ +# Flutter Markdown + +A markdown renderer for Flutter. It supports the +[original format](https://daringfireball.net/projects/markdown/), but no inline +html. + +## Getting Started + +Using the Markdown widget is simple, just pass in the source markdown as a +string: + + new Markdown(data: markdownSource); + +If you do not want the padding or scrolling behavior, use the MarkdownBody +instead: + + new MarkdownBody(data: markdownSource); + +By default, Markdown uses the formatting from the current material design theme, +but it's possible to create your own custom styling. Use the MarkdownStyle class +to pass in your own style. If you don't want to use Markdown outside of material +design, use the MarkdownRaw class. diff --git a/packages/flutter_markdown/example/demo.dart b/packages/flutter_markdown/example/demo.dart new file mode 100644 index 0000000000..14bce27408 --- /dev/null +++ b/packages/flutter_markdown/example/demo.dart @@ -0,0 +1,36 @@ +// Copyright 2016 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; + +const String _kMarkdownData = """# Markdown Example +Markdown allows you to easily include formatted text, images, and even formatted Dart code in your app. + +## Styling +Style text as _italic_, __bold__, or `inline code`. + +- Use bulleted lists +- To better clarify +- Your points + +## Code blocks +Formatted Dart code looks really pretty too. This is an example of how to create your own Markdown widget: + + new Markdown(data: "Hello _world_!"); + +Enjoy! +"""; + +void main() { + runApp(new MaterialApp( + title: "Markdown Demo", + routes: { + '/': (RouteArguments args) => new Scaffold( + toolBar: new ToolBar(center: new Text("Markdown Demo")), + body: new Markdown(data: _kMarkdownData) + ) + } + )); +} diff --git a/packages/flutter_markdown/lib/flutter_markdown.dart b/packages/flutter_markdown/lib/flutter_markdown.dart new file mode 100644 index 0000000000..736e84f2db --- /dev/null +++ b/packages/flutter_markdown/lib/flutter_markdown.dart @@ -0,0 +1,8 @@ +// Copyright 2016 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +library flutter_markdown; + +export 'src/markdown.dart'; +export 'src/markdown_style.dart'; diff --git a/packages/flutter_markdown/lib/flutter_markdown_raw.dart b/packages/flutter_markdown/lib/flutter_markdown_raw.dart new file mode 100644 index 0000000000..d7fed2c666 --- /dev/null +++ b/packages/flutter_markdown/lib/flutter_markdown_raw.dart @@ -0,0 +1,8 @@ +// Copyright 2016 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +library flutter_markdown; + +export 'src/markdown_raw.dart'; +export 'src/markdown_style_raw.dart'; diff --git a/packages/flutter_markdown/lib/src/markdown.dart b/packages/flutter_markdown/lib/src/markdown.dart new file mode 100644 index 0000000000..3b437b1241 --- /dev/null +++ b/packages/flutter_markdown/lib/src/markdown.dart @@ -0,0 +1,84 @@ +// Copyright 2016 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; + +import 'markdown_raw.dart'; +import 'markdown_style.dart'; + +/// A [Widget] that renders markdown formatted text. It supports all standard +/// markdowns from the original markdown specification found here: +/// https://daringfireball.net/projects/markdown/ The rendered markdown is +/// placed in a padded scrolling view port. If you do not want the scrolling +/// behaviour, use the [MarkdownBody] class instead. +class Markdown extends MarkdownRaw { + + /// Creates a new Markdown [Widget] that renders the markdown formatted string + /// passed in as [data]. By default the markdown will be rendered using the + /// styles from the current theme, but you can optionally pass in a custom + /// [markdownStyle] that specifies colors and fonts to use. Code blocks are + /// by default not using syntax highlighting, but it's possible to pass in + /// a custom [syntaxHighlighter]. + /// + /// new Markdown(data: "Hello _world_!"); + Markdown({ + String data, + SyntaxHighlighter syntaxHighlighter, + MarkdownStyle markdownStyle + }) : super( + data: data, + syntaxHighlighter: syntaxHighlighter, + markdownStyle: markdownStyle + ); + + MarkdownBody createMarkdownBody({ + String data, + MarkdownStyle markdownStyle, + SyntaxHighlighter syntaxHighlighter + }) { + return new MarkdownBody( + data: data, + markdownStyle: markdownStyle, + syntaxHighlighter: syntaxHighlighter + ); + } +} + +/// A [Widget] that renders markdown formatted text. It supports all standard +/// markdowns from the original markdown specification found here: +/// https://daringfireball.net/projects/markdown/ This class doesn't implement +/// any scrolling behavior, if you want scrolling either wrap the widget in +/// a [ScrollableViewport] or use the [Markdown] widget. +class MarkdownBody extends MarkdownBodyRaw { + + /// Creates a new Markdown [Widget] that renders the markdown formatted string + /// passed in as [data]. By default the markdown will be rendered using the + /// styles from the current theme, but you can optionally pass in a custom + /// [markdownStyle] that specifies colors and fonts to use. Code blocks are + /// by default not using syntax highlighting, but it's possible to pass in + /// a custom [syntaxHighlighter]. + /// + /// Typically, you may want to wrap the [MarkdownBody] widget in a [Padding] and + /// a [ScrollableViewport], or use the [Markdown] class + /// + /// new ScrollableViewport( + /// child: new Padding( + /// padding: new EdgeDims.all(16.0), + /// child: new Markdown(data: markdownSource) + /// ) + /// ) + MarkdownBody({ + String data, + SyntaxHighlighter syntaxHighlighter, + MarkdownStyle markdownStyle + }) : super( + data: data, + syntaxHighlighter: syntaxHighlighter, + markdownStyle: markdownStyle + ); + + MarkdownStyle createDefaultStyle(BuildContext context) { + return new MarkdownStyle.defaultFromTheme(Theme.of(context)); + } +} diff --git a/packages/flutter_markdown/lib/src/markdown_raw.dart b/packages/flutter_markdown/lib/src/markdown_raw.dart new file mode 100644 index 0000000000..043e3e17d3 --- /dev/null +++ b/packages/flutter_markdown/lib/src/markdown_raw.dart @@ -0,0 +1,433 @@ +// Copyright 2016 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:markdown/markdown.dart' as md; +import 'package:flutter/widgets.dart'; +import 'markdown_style_raw.dart'; + + +/// A [Widget] that renders markdown formatted text. It supports all standard +/// markdowns from the original markdown specification found here: +/// https://daringfireball.net/projects/markdown/ The rendered markdown is +/// placed in a padded scrolling view port. If you do not want the scrolling +/// behaviour, use the [MarkdownBodyRaw] class instead. +class MarkdownRaw extends StatelessComponent { + + /// Creates a new Markdown [Widget] that renders the markdown formatted string + /// passed in as [data]. By default the markdown will be rendered using the + /// styles from the current theme, but you can optionally pass in a custom + /// [markdownStyle] that specifies colors and fonts to use. Code blocks are + /// by default not using syntax highlighting, but it's possible to pass in + /// a custom [syntaxHighlighter]. + /// + /// new MarkdownRaw(data: "Hello _world_!", markdownStyle: myStyle); + MarkdownRaw({ + this.data, + this.markdownStyle, + this.syntaxHighlighter, + this.padding: const EdgeDims.all(16.0) + }); + + /// Markdown styled text + final String data; + + /// Style used for rendering the markdown + final MarkdownStyleRaw markdownStyle; + + /// The syntax highlighter used to color text in code blocks + final SyntaxHighlighter syntaxHighlighter; + + /// Padding used + final EdgeDims padding; + + Widget build(BuildContext context) { + return new ScrollableViewport( + child: new Padding( + padding: padding, + child: createMarkdownBody( + data: data, + markdownStyle: markdownStyle, + syntaxHighlighter: syntaxHighlighter + ) + ) + ); + } + + MarkdownBodyRaw createMarkdownBody({ + String data, + MarkdownStyleRaw markdownStyle, + SyntaxHighlighter syntaxHighlighter + }) { + return new MarkdownBodyRaw( + data: data, + markdownStyle: markdownStyle, + syntaxHighlighter: syntaxHighlighter + ); + } +} + +/// A [Widget] that renders markdown formatted text. It supports all standard +/// markdowns from the original markdown specification found here: +/// https://daringfireball.net/projects/markdown/ This class doesn't implement +/// any scrolling behavior, if you want scrolling either wrap the widget in +/// a [ScrollableViewport] or use the [MarkdownRaw] widget. +class MarkdownBodyRaw extends StatefulComponent { + + /// Creates a new Markdown [Widget] that renders the markdown formatted string + /// passed in as [data]. You need to pass in a [markdownStyle] that defines + /// how the code is rendered. Code blocks are by default not using syntax + /// highlighting, but it's possible to pass in a custom [syntaxHighlighter]. + /// + /// Typically, you may want to wrap the [MarkdownBodyRaw] widget in a + /// [Padding] and a [ScrollableViewport], or use the [Markdown class] + /// + /// new ScrollableViewport( + /// child: new Padding( + /// padding: new EdgeDims.all(16.0), + /// child: new MarkdownBodyRaw( + /// data: markdownSource, + /// markdownStyle: myStyle + /// ) + /// ) + /// ) + MarkdownBodyRaw({ + this.data, + this.markdownStyle, + this.syntaxHighlighter + }); + + /// Markdown styled text + final String data; + + /// Style used for rendering the markdown + final MarkdownStyleRaw markdownStyle; + + /// The syntax highlighter used to color text in code blocks + final SyntaxHighlighter syntaxHighlighter; + + _MarkdownBodyRawState createState() => new _MarkdownBodyRawState(); + + MarkdownStyleRaw createDefaultStyle(BuildContext context) => null; +} + +class _MarkdownBodyRawState extends State { + + void initState() { + super.initState(); + + MarkdownStyleRaw markdownStyle = config.markdownStyle ?? config.createDefaultStyle(context); + SyntaxHighlighter syntaxHighlighter = config.syntaxHighlighter ?? new _DefaultSyntaxHighlighter(markdownStyle.code); + + _cachedBlocks = _blocksFromMarkup(config.data, markdownStyle, syntaxHighlighter); + } + + List<_Block> _cachedBlocks; + + Widget build(BuildContext context) { + List blocks = []; + for (_Block block in _cachedBlocks) { + blocks.add(block.build(context)); + } + + return new Column( + alignItems: FlexAlignItems.stretch, + children: blocks + ); + } +} + +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 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 nodes, MarkdownStyleRaw markdownStyle, SyntaxHighlighter syntaxHighlighter) { + assert(markdownStyle != null); + + _blocks = <_Block>[]; + _listIndents = []; + _markdownStyle = markdownStyle; + _syntaxHighlighter = syntaxHighlighter; + + for (final md.Node node in nodes) { + node.accept(this); + } + + return _blocks; + } + + List<_Block> _blocks; + List _listIndents; + MarkdownStyleRaw _markdownStyle; + SyntaxHighlighter _syntaxHighlighter; + + void visitText(md.Text text) { + _MarkdownNodeList topList = _currentBlock.stack.last; + List<_MarkdownNode> top = topList.list; + + if (_currentBlock.tag == 'pre') + top.add(new _MarkdownNodeTextSpan(_syntaxHighlighter.format(text.text))); + else + top.add(new _MarkdownNodeString(text.text)); + } + + bool visitElementBefore(md.Element element) { + if (_isListTag(element.tag)) + _listIndents.add(element.tag); + + if (_isBlockTag(element.tag)) { + List<_Block> blockList; + if (_currentBlock == null) + blockList = _blocks; + else + blockList = _currentBlock.subBlocks; + + _Block newBlock = new _Block(element.tag, element.attributes, _markdownStyle, new List.from(_listIndents), blockList.length); + blockList.add(newBlock); + } else { + TextStyle style = _markdownStyle.styles[element.tag] ?? new TextStyle(); + List<_MarkdownNode> styleElement = <_MarkdownNode>[new _MarkdownNodeTextStyle(style)]; + _currentBlock.stack.add(new _MarkdownNodeList(styleElement)); + } + return true; + } + + void visitElementAfter(md.Element element) { + if (_isListTag(element.tag)) + _listIndents.removeLast(); + + if (_isBlockTag(element.tag)) { + if (_currentBlock.stack.length > 0) { + _MarkdownNodeList stackList = _currentBlock.stack.first; + _currentBlock.stack = stackList.list; + _currentBlock.open = false; + } else { + _currentBlock.stack = <_MarkdownNode>[new _MarkdownNodeString('')]; + } + } else { + if (_currentBlock.stack.length > 1) { + _MarkdownNodeList poppedList = _currentBlock.stack.last; + List<_MarkdownNode> popped = poppedList.list; + _currentBlock.stack.removeLast(); + + _MarkdownNodeList topList = _currentBlock.stack.last; + List<_MarkdownNode> top = topList.list; + top.add(new _MarkdownNodeList(popped)); + } + } + } + + static const List _kBlockTags = const ['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'li', 'blockquote', 'img', 'pre', 'ol', 'ul']; + static const List _kListTags = const ['ul', 'ol']; + + bool _isBlockTag(String tag) { + return _kBlockTags.contains(tag); + } + + bool _isListTag(String tag) { + return _kListTags.contains(tag); + } + + _Block get _currentBlock => _currentBlockInList(_blocks); + + _Block _currentBlockInList(List<_Block> blocks) { + if (blocks.isEmpty) + return null; + + if (!blocks.last.open) + return null; + + _Block childBlock = _currentBlockInList(blocks.last.subBlocks); + if (childBlock != null) + return childBlock; + + return blocks.last; + } +} + +abstract class _MarkdownNode { +} + +class _MarkdownNodeList extends _MarkdownNode { + _MarkdownNodeList(this.list); + List<_MarkdownNode> list; +} + +class _MarkdownNodeTextStyle extends _MarkdownNode { + _MarkdownNodeTextStyle(this.style); + TextStyle style; +} + +class _MarkdownNodeString extends _MarkdownNode { + _MarkdownNodeString(this.string); + String string; +} + +class _MarkdownNodeTextSpan extends _MarkdownNode { + _MarkdownNodeTextSpan(this.textSpan); + TextSpan textSpan; +} + +class _Block { + _Block(this.tag, this.attributes, this.markdownStyle, this.listIndents, this.blockPosition) { + TextStyle style = markdownStyle.styles[tag]; + if (style == null) + style = new TextStyle(color: const Color(0xffff0000)); + + stack = <_MarkdownNode>[new _MarkdownNodeList(<_MarkdownNode>[new _MarkdownNodeTextStyle(style)])]; + subBlocks = <_Block>[]; + } + + final String tag; + final Map attributes; + final MarkdownStyleRaw markdownStyle; + final List listIndents; + final int blockPosition; + + List<_MarkdownNode> stack; + List<_Block> subBlocks; + + bool get open => _open; + void set open(bool open) { + _open = open; + if (!open && subBlocks.length > 0) + subBlocks.last.isLast = true; + } + + bool _open = true; + bool isLast = false; + + Widget build(BuildContext context) { + + if (tag == 'img') { + return _buildImage(context, attributes['src']); + } + + double spacing = markdownStyle.blockSpacing; + if (isLast) spacing = 0.0; + + Widget contents; + + if (subBlocks.length > 0) { + List subWidgets = []; + for (_Block subBlock in subBlocks) { + subWidgets.add(subBlock.build(context)); + } + + contents = new Column( + alignItems: FlexAlignItems.stretch, + children: subWidgets + ); + } else { + contents = new RichText(text: _stackToTextSpan(new _MarkdownNodeList(stack))); + + if (listIndents.length > 0) { + Widget bullet; + if (listIndents.last == 'ul') { + bullet = new Text( + '•', + style: new TextStyle(textAlign: TextAlign.center) + ); + } + else { + bullet = new Padding( + padding: new EdgeDims.only(right: 5.0), + child: new Text( + "${blockPosition + 1}.", + style: new TextStyle(textAlign: TextAlign.right) + ) + ); + } + + contents = new Row( + alignItems: FlexAlignItems.start, + children: [ + new SizedBox( + width: listIndents.length * markdownStyle.listIndent, + child: bullet + ), + new Flexible(child: contents) + ] + ); + } + } + + BoxDecoration decoration; + EdgeDims padding; + + if (tag == 'blockquote') { + decoration = markdownStyle.blockquoteDecoration; + padding = new EdgeDims.all(markdownStyle.blockquotePadding); + } else if (tag == 'pre') { + decoration = markdownStyle.codeblockDecoration; + padding = new EdgeDims.all(markdownStyle.codeblockPadding); + } + + return new Container( + padding: padding, + margin: new EdgeDims.only(bottom: spacing), + child: contents, + decoration: decoration + ); + } + + TextSpan _stackToTextSpan(_MarkdownNode stack) { + if (stack is _MarkdownNodeTextSpan) + return stack.textSpan; + + if (stack is _MarkdownNodeList) { + List<_MarkdownNode> list = stack.list; + _MarkdownNodeTextStyle styleNode = list[0]; + TextStyle style = styleNode.style; + + List children = []; + for (int i = 1; i < list.length; i++) { + children.add(_stackToTextSpan(list[i])); + } + return new TextSpan(style: style, children: children); + } + + if (stack is _MarkdownNodeString) { + return new TextSpan(text: stack.string); + } + + return null; + } + + Widget _buildImage(BuildContext context, String src) { + List parts = src.split('#'); + if (parts.length == 0) return new Container(); + + String path = parts.first; + double width; + double height; + if (parts.length == 2) { + List dimensions = parts.last.split('x'); + if (dimensions.length == 2) { + width = double.parse(dimensions[0]); + height = double.parse(dimensions[1]); + } + } + + return new NetworkImage(src: path, width: width, height: height); + } +} + +abstract class SyntaxHighlighter { + TextSpan format(String source); +} + +class _DefaultSyntaxHighlighter extends SyntaxHighlighter{ + _DefaultSyntaxHighlighter(this.style); + final TextStyle style; + + TextSpan format(String source) { + return new TextSpan(style: style, children: [new TextSpan(text: source)]); + } +} diff --git a/packages/flutter_markdown/lib/src/markdown_style.dart b/packages/flutter_markdown/lib/src/markdown_style.dart new file mode 100644 index 0000000000..d71afce174 --- /dev/null +++ b/packages/flutter_markdown/lib/src/markdown_style.dart @@ -0,0 +1,77 @@ +// Copyright 2016 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'markdown_style_raw.dart'; + +/// Style used for rendering markdown formatted text using the [MarkdownBody] +/// widget. +class MarkdownStyle extends MarkdownStyleRaw{ + + /// Creates a [MarkdownStyle] from the [TextStyle]s in the provided [theme]. + MarkdownStyle.defaultFromTheme(ThemeData theme) : super( + a: new TextStyle(color: Colors.blue[500]), + p: theme.text.body1, + code: new TextStyle( + color: Colors.grey[700], + fontFamily: "monospace", + fontSize: theme.text.body1.fontSize * 0.85 + ), + h1: theme.text.headline, + h2: theme.text.title, + h3: theme.text.subhead, + h4: theme.text.body2, + h5: theme.text.body2, + h6: theme.text.body2, + em: new TextStyle(fontStyle: FontStyle.italic), + strong: new TextStyle(fontWeight: FontWeight.bold), + blockquote: theme.text.body1, + blockSpacing: 8.0, + listIndent: 32.0, + blockquotePadding: 8.0, + blockquoteDecoration: new BoxDecoration( + backgroundColor: Colors.blue[100], + borderRadius: 2.0 + ), + codeblockPadding: 8.0, + codeblockDecoration: new BoxDecoration( + backgroundColor: Colors.grey[100], + borderRadius: 2.0 + ) + ); + + /// Creates a [MarkdownStyle] from the [TextStyle]s in the provided [theme]. + /// This style uses larger fonts for the headings than in + /// [MarkdownStyle.defaultFromTheme]. + MarkdownStyle.largeFromTheme(ThemeData theme) : super ( + a: new TextStyle(color: Colors.blue[500]), + p: theme.text.body1, + code: new TextStyle( + color: Colors.grey[700], + fontFamily: "monospace", + fontSize: theme.text.body1.fontSize * 0.85 + ), + h1: theme.text.display3, + h2: theme.text.display2, + h3: theme.text.display1, + h4: theme.text.headline, + h5: theme.text.title, + h6: theme.text.subhead, + em: new TextStyle(fontStyle: FontStyle.italic), + strong: new TextStyle(fontWeight: FontWeight.bold), + blockquote: theme.text.body1, + blockSpacing: 8.0, + listIndent: 32.0, + blockquotePadding: 8.0, + blockquoteDecoration: new BoxDecoration( + backgroundColor: Colors.blue[100], + borderRadius: 2.0 + ), + codeblockPadding: 8.0, + codeblockDecoration: new BoxDecoration( + backgroundColor: Colors.grey[100], + borderRadius: 2.0 + ) + ); +} diff --git a/packages/flutter_markdown/lib/src/markdown_style_raw.dart b/packages/flutter_markdown/lib/src/markdown_style_raw.dart new file mode 100644 index 0000000000..4b694855bd --- /dev/null +++ b/packages/flutter_markdown/lib/src/markdown_style_raw.dart @@ -0,0 +1,120 @@ +// Copyright 2016 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/widgets.dart'; + +/// Style used for rendering markdown formatted text using the [MarkdownBody] +/// widget. +class MarkdownStyleRaw { + + /// Creates a new [MarkdownStyleRaw] + MarkdownStyleRaw({ + this.a, + this.p, + this.code, + this.h1, + this.h2, + this.h3, + this.h4, + this.h5, + this.h6, + this.em, + this.strong, + this.blockquote, + this.blockSpacing, + this.listIndent, + this.blockquotePadding, + this.blockquoteDecoration, + this.codeblockPadding, + this.codeblockDecoration + }) { + _init(); + } + + /// Creates a new [MarkdownStyleRaw] based on the current style, with the + /// provided paramaters overridden. + MarkdownStyleRaw copyWith({ + TextStyle a, + TextStyle p, + TextStyle code, + TextStyle h1, + TextStyle h2, + TextStyle h3, + TextStyle h4, + TextStyle h5, + TextStyle h6, + TextStyle em, + TextStyle strong, + TextStyle blockquote, + double blockSpacing, + double listIndent, + double blockquotePadding, + BoxDecoration blockquoteDecoration, + double codeblockPadding, + BoxDecoration codeblockDecoration + }) { + return new MarkdownStyleRaw( + a: a != null ? a : this.a, + p: p != null ? p : this.p, + code: code != null ? code : this.code, + h1: h1 != null ? h1 : this.h1, + h2: h2 != null ? h2 : this.h2, + h3: h3 != null ? h3 : this.h3, + h4: h4 != null ? h4 : this.h4, + h5: h5 != null ? h5 : this.h5, + h6: h6 != null ? h6 : this.h6, + em: em != null ? em : this.em, + strong: strong != null ? strong : this.strong, + blockquote: blockquote != null ? blockquote : this.blockquote, + blockSpacing: blockSpacing != null ? blockSpacing : this.blockSpacing, + listIndent: listIndent != null ? listIndent : this.listIndent, + blockquotePadding: blockquotePadding != null ? blockquotePadding : this.blockquotePadding, + blockquoteDecoration: blockquoteDecoration != null ? blockquoteDecoration : this.blockquoteDecoration, + codeblockPadding: codeblockPadding != null ? codeblockPadding : this.codeblockPadding, + codeblockDecoration: codeblockDecoration != null ? codeblockDecoration : this.codeblockDecoration + ); + } + + final TextStyle a; + final TextStyle p; + final TextStyle code; + final TextStyle h1; + final TextStyle h2; + final TextStyle h3; + final TextStyle h4; + final TextStyle h5; + final TextStyle h6; + final TextStyle em; + final TextStyle strong; + final TextStyle blockquote; + final double blockSpacing; + final double listIndent; + final double blockquotePadding; + final BoxDecoration blockquoteDecoration; + final double codeblockPadding; + final BoxDecoration codeblockDecoration; + + Map _styles; + + Map get styles => _styles; + + void _init() { + _styles = { + 'a': a, + 'p': p, + 'li': p, + 'code': code, + 'pre': p, + 'h1': h1, + 'h2': h2, + 'h3': h3, + 'h4': h4, + 'h5': h5, + 'h6': h6, + 'em': em, + 'strong': strong, + 'blockquote': blockquote + }; + } +} diff --git a/packages/flutter_markdown/pubspec.yaml b/packages/flutter_markdown/pubspec.yaml new file mode 100644 index 0000000000..2a85b010a6 --- /dev/null +++ b/packages/flutter_markdown/pubspec.yaml @@ -0,0 +1,18 @@ +name: flutter_markdown +description: A markdown renderer for Flutter. +version: 0.1.0 +author: Flutter Authors +homepage: http://flutter.io + +dependencies: + flutter: + path: ../flutter + markdown: "0.9.0" + string_scanner: "0.1.4+1" + +dev_dependencies: + flutter_tools: + path: ../flutter_tools + test: any # constrained by the dependency in flutter_tools + flutter_test: + path: ../flutter_test diff --git a/packages/flutter_markdown/test/flutter_markdown_test.dart b/packages/flutter_markdown/test/flutter_markdown_test.dart new file mode 100644 index 0000000000..f738572fa8 --- /dev/null +++ b/packages/flutter_markdown/test/flutter_markdown_test.dart @@ -0,0 +1,134 @@ +import 'package:flutter_markdown/flutter_markdown.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter/widgets.dart'; +import 'package:test/test.dart'; +import 'package:flutter/material.dart'; + +void main() { + test("Simple string", () { + 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 elements = _listElements(tester); + _expectWidgetTypes(elements, [MarkdownBody, Column, Container, Padding, RichText]); + expect(textSpan.children[0].text, equals("Hello")); + }); + }); + + test("Header", () { + 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 elements = _listElements(tester); + _expectWidgetTypes(elements, [MarkdownBody, Column, Container, Padding, RichText]); + expect(textSpan.children[0].text, equals("Header")); + }); + }); + + test("Empty string", () { + testWidgets((WidgetTester tester) { + tester.pumpWidget(new MarkdownBody(data: "")); + + List elements = _listElements(tester); + _expectWidgetTypes(elements, [MarkdownBody, Column]); + }); + }); + + test("Ordered list", () { + testWidgets((WidgetTester tester) { + tester.pumpWidget(new MarkdownBody(data: "1. Item 1\n1. Item 2\n2. Item 3")); + + List elements = _listElements(tester); + _expectTextStrings(elements, [ + "1.", + "Item 1", + "2.", + "Item 2", + "3.", + "Item 3"] + ); + }); + }); + + test("Unordered list", () { + testWidgets((WidgetTester tester) { + tester.pumpWidget(new MarkdownBody(data: "- Item 1\n- Item 2\n- Item 3")); + + List elements = _listElements(tester); + _expectTextStrings(elements, [ + "•", + "Item 1", + "•", + "Item 2", + "•", + "Item 3"] + ); + }); + }); + + test("Scrollable wrapping", () { + testWidgets((WidgetTester tester) { + tester.pumpWidget(new Markdown(data: "")); + + List elements = _listElements(tester); + for (Element element in elements) print("e: $element"); + _expectWidgetTypes(elements, [ + Markdown, + ScrollableViewport, + null, null, null, null, null, // ScrollableViewport internals + Padding, + MarkdownBody, + Column + ]); + }); + }); +} + +List _listElements(WidgetTester tester) { + List elements = []; + tester.walkElements((Element element) { + elements.add(element); + }); + return elements; +} + +void _expectWidgetTypes(List elements, List types) { + expect(elements.length, equals(types.length)); + for (int i = 0; i < elements.length; i += 1) { + Element element = elements[i]; + Type type = types[i]; + if (type == null) continue; + expect(element.widget.runtimeType, equals(type)); + } +} + +void _expectTextStrings(List elements, List strings) { + int currentString = 0; + for (Element element in elements) { + Widget widget = element.widget; + if (widget is RichText) { + TextSpan span = widget.text; + String text = _extractTextFromTextSpan(span); + expect(text, equals(strings[currentString])); + currentString += 1; + } + } +} + +String _extractTextFromTextSpan(TextSpan span) { + String text = span.text ?? ""; + if (span.children != null) { + for (TextSpan child in span.children) { + text += _extractTextFromTextSpan(child); + } + } + return text; +}