diff --git a/packages/flutter_markdown/.cirrus.yml b/packages/flutter_markdown/.cirrus.yml new file mode 100644 index 000000000000..49fe2845d91b --- /dev/null +++ b/packages/flutter_markdown/.cirrus.yml @@ -0,0 +1,18 @@ +container: + image: cirrusci/flutter:latest + +test_task: + pub_cache: + folder: ~/.pub-cache + upgrade_script: + - flutter channel master + - flutter upgrade + test_script: flutter test + +analyze_task: + pub_cache: + folder: ~/.pub-cache + upgrade_script: + - flutter channel master + - flutter upgrade + analyze_script: flutter analyze \ No newline at end of file diff --git a/packages/flutter_markdown/.gitignore b/packages/flutter_markdown/.gitignore new file mode 100644 index 000000000000..5246badb9d97 --- /dev/null +++ b/packages/flutter_markdown/.gitignore @@ -0,0 +1,15 @@ +.DS_Store +.atom/ +.idea +.packages +.dart_tool/ +.pub/ +ios/.generated/ +ios/Flutter/Generated.xcconfig +ios/Runner/GeneratedPluginRegistrant.* +packages +pubspec.lock + +# Visual Studio Code related +.vscode/ +.history/ diff --git a/packages/flutter_markdown/.travis.yml b/packages/flutter_markdown/.travis.yml new file mode 100644 index 000000000000..191da0afb91f --- /dev/null +++ b/packages/flutter_markdown/.travis.yml @@ -0,0 +1,7 @@ +os: linux +before_script: + - git clone https://github.com/flutter/flutter.git --depth 1 + - export PATH=`pwd`/flutter/bin:`pwd`/flutter/bin/cache/dart-sdk/bin:$PATH + - flutter doctor +script: + - flutter test diff --git a/packages/flutter_markdown/CHANGELOG.md b/packages/flutter_markdown/CHANGELOG.md new file mode 100644 index 000000000000..195659ea8401 --- /dev/null +++ b/packages/flutter_markdown/CHANGELOG.md @@ -0,0 +1,43 @@ +## 0.2.0 + +* Updated environment sdk constraints to make the package + Dart 2 compatible. As a result, usage of this version and higher + requires a Dart 2 SDK. + +## 0.1.6 + +* Updated `markdown` dependency. + +## 0.1.5 + +* Add `mockito` as a dev dependency. Eliminate use of `package:http`, which + is no longer part of Flutter. + +## 0.1.4 + +* Add `li` style to bullets + +## 0.1.3 + +* Add `path` and `http` as declared dependencies in `pubspec.yaml` + +## 0.1.2 + +* Add support for horizontal rules. +* Fix the `onTap` callback on images nested in hyperlinks + +## 0.1.1 + +* Add support for local file paths in image links. Make sure to set the + `imageDirectory` property to specify the base directory containing the image + files. + +## 0.1.0 + +* Roll the dependency on `markdown` to 1.0.0 +* Add a test and example for image links +* Fix the `onTap` callback on hyperlinks + +## 0.0.9 + +* First published version diff --git a/packages/flutter_markdown/LICENSE b/packages/flutter_markdown/LICENSE new file mode 100644 index 000000000000..e7892520aaa9 --- /dev/null +++ b/packages/flutter_markdown/LICENSE @@ -0,0 +1,27 @@ +// Copyright 2017 Google, Inc. All rights reserved. +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions are +// met: +// +// * Redistributions of source code must retain the above copyright +// notice, this list of conditions and the following disclaimer. +// * Redistributions in binary form must reproduce the above +// copyright notice, this list of conditions and the following disclaimer +// in the documentation and/or other materials provided with the +// distribution. +// * Neither the name of Google Inc. nor the names of its +// contributors may be used to endorse or promote products derived from +// this software without specific prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +// A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +// OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +// DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +// THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/packages/flutter_markdown/README.md b/packages/flutter_markdown/README.md new file mode 100644 index 000000000000..05bb2662cb6b --- /dev/null +++ b/packages/flutter_markdown/README.md @@ -0,0 +1,37 @@ +# Flutter Markdown +[![pub package](https://img.shields.io/pub/v/flutter_markdown.svg)](https://pub.dartlang.org/packages/flutter_markdown) +[![Build Status](https://travis-ci.org/flutter/flutter_markdown.svg?branch=master)](https://travis-ci.org/flutter/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. + +## Image support + +The `Img` tag only supports the following image locations. It specifically +does not support image locations referring to bundled assets. + +* From the network: Use a URL prefixed by either `http://` or `https://`. + +* From local files on the device: Use an absolute path to the file, for example by + concatenating the file name with the path returned by a known storage location, + such as those provided by the [`path_provider`](https://pub.dartlang.org/packages/path_provider) + plugin. diff --git a/packages/flutter_markdown/example/example.dart b/packages/flutter_markdown/example/example.dart new file mode 100644 index 000000000000..8d3be041dfc4 --- /dev/null +++ b/packages/flutter_markdown/example/example.dart @@ -0,0 +1,57 @@ +// 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 _markdownData = """# 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 + +## Links +You can use [hyperlinks](hyperlink) in markdown + +## Images + +You can include images: + +![Flutter logo](https://flutter.io/images/flutter-mark-square-100.png#100x100) + +## Markdown widget + +This is an example of how to create your own Markdown widget: + + new Markdown(data: 'Hello _world_!'); + +## Code blocks +Formatted Dart code looks really pretty too: + +``` +void main() { + runApp(new MaterialApp( + home: new Scaffold( + body: new Markdown(data: markdownData) + ) + )); +} +``` + +Enjoy! +"""; + +void main() { + runApp(new MaterialApp( + title: "Markdown Demo", + home: new Scaffold( + appBar: new AppBar(title: const Text('Markdown Demo')), + body: const Markdown(data: _markdownData) + ) + )); +} diff --git a/packages/flutter_markdown/flutter_markdown.iml b/packages/flutter_markdown/flutter_markdown.iml new file mode 100644 index 000000000000..4f0181093da8 --- /dev/null +++ b/packages/flutter_markdown/flutter_markdown.iml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/flutter_markdown/lib/flutter_markdown.dart b/packages/flutter_markdown/lib/flutter_markdown.dart new file mode 100644 index 000000000000..8d7ed6ea0826 --- /dev/null +++ b/packages/flutter_markdown/lib/flutter_markdown.dart @@ -0,0 +1,10 @@ +// 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. + +/// A library to render markdown formatted text. +library flutter_markdown; + +export 'src/builder.dart'; +export 'src/style_sheet.dart'; +export 'src/widget.dart'; diff --git a/packages/flutter_markdown/lib/src/builder.dart b/packages/flutter_markdown/lib/src/builder.dart new file mode 100644 index 000000000000..059bbadbe841 --- /dev/null +++ b/packages/flutter_markdown/lib/src/builder.dart @@ -0,0 +1,350 @@ +// 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 'dart:io'; + +import 'package:flutter/gestures.dart'; +import 'package:flutter/widgets.dart'; +import 'package:markdown/markdown.dart' as md; +import 'package:path/path.dart' as p; + +import 'style_sheet.dart'; + +final Set _kBlockTags = new Set.from([ + 'p', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'li', + 'blockquote', + 'pre', + 'ol', + 'ul', + 'hr', +]); + +const List _kListTags = const ['ul', 'ol']; + +bool _isBlockTag(String tag) => _kBlockTags.contains(tag); +bool _isListTag(String tag) => _kListTags.contains(tag); + +class _BlockElement { + _BlockElement(this.tag); + + final String tag; + final List children = []; + + 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 { + _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 children = []; +} + +/// A delegate used by [MarkdownBuilder] to control the widgets it creates. +abstract class MarkdownBuilderDelegate { + /// Returns a gesture recognizer to use for an `a` element with the given + /// `href` attribute. + GestureRecognizer createLink(String href); + + /// Returns formatted text to use to display the given contents of a `pre` + /// element. + /// + /// The `styleSheet` is the value of [MarkdownBuilder.styleSheet]. + TextSpan formatText(MarkdownStyleSheet styleSheet, String code); +} + +/// Builds a [Widget] tree from parsed Markdown. +/// +/// See also: +/// +/// * [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 }); + + /// A delegate that controls how link and `pre` elements behave. + final MarkdownBuilderDelegate delegate; + + /// 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; + + final List _listIndents = []; + final List<_BlockElement> _blocks = <_BlockElement>[]; + final List<_InlineElement> _inlines = <_InlineElement>[]; + final List _linkHandlers = []; + + + /// Returns widgets that display the given Markdown nodes. + /// + /// The returned widgets are typically used as children in a [ListView]. + List build(List nodes) { + _listIndents.clear(); + _blocks.clear(); + _inlines.clear(); + _linkHandlers.clear(); + + _blocks.add(new _BlockElement(null)); + + for (md.Node node in nodes) { + assert(_blocks.length == 1); + node.accept(this); + } + + assert(_inlines.isEmpty); + return _blocks.single.children; + } + + @override + 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(new RichText(text: span)); + } + + @override + bool visitElementBefore(md.Element element) { + final String tag = element.tag; + if (_isBlockTag(tag)) { + _addAnonymousBlockIfNeeded(styleSheet.styles[tag]); + if (_isListTag(tag)) + _listIndents.add(tag); + _blocks.add(new _BlockElement(tag)); + } else { + _addParentInlineIfNeeded(_blocks.last.tag); + + TextStyle parentStyle = _inlines.last.style; + _inlines.add(new _InlineElement( + tag, + style: parentStyle.merge(styleSheet.styles[tag]), + )); + } + + if (tag == 'a') { + _linkHandlers.add(delegate.createLink(element.attributes['href'])); + } + + return true; + } + + @override + void visitElementAfter(md.Element element) { + final String tag = element.tag; + + if (_isBlockTag(tag)) { + _addAnonymousBlockIfNeeded(styleSheet.styles[tag]); + + final _BlockElement current = _blocks.removeLast(); + Widget 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: [ + 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, + ), + ); + } else if (tag == 'hr') { + child = new DecoratedBox( + decoration: styleSheet.horizontalRuleDecoration, + child: child, + ); + } + + _addBlockChild(child); + } else { + final _InlineElement current = _inlines.removeLast(); + final _InlineElement parent = _inlines.last; + + if (tag == 'img') { + // create an image widget for this image + current.children.add(_buildImage(element.attributes['src'])); + } else if (tag == 'a') { + _linkHandlers.removeLast(); + } + + if (current.children.isNotEmpty) { + parent.children.addAll(current.children); + } + } + } + + Widget _buildImage(String src) { + final List parts = src.split('#'); + if (parts.isEmpty) + return const SizedBox(); + + final String path = parts.first; + double width; + double height; + if (parts.length == 2) { + final List dimensions = parts.last.split('x'); + if (dimensions.length == 2) { + width = double.parse(dimensions[0]); + height = double.parse(dimensions[1]); + } + } + + Uri uri = Uri.parse(path); + Widget child; + if (uri.scheme == 'http' || uri.scheme == 'https') { + child = new Image.network(uri.toString(), width: width, height: height); + } else if (uri.scheme == 'data') { + child = _handleDataSchemeUri(uri, width, height); + } else { + String filePath = (imageDirectory == null + ? uri.toFilePath() + : p.join(imageDirectory.path, uri.toFilePath())); + child = new Image.file(new File(filePath), width: width, height: height); + } + + if (_linkHandlers.isNotEmpty) { + TapGestureRecognizer recognizer = _linkHandlers.last; + return new GestureDetector(child: child, onTap: recognizer.onTap); + } else { + return child; + } + } + + Widget _handleDataSchemeUri(Uri uri, final double width, final double height) { + final String mimeType = uri.data.mimeType; + if (mimeType.startsWith('image/')) { + return new Image.memory(uri.data.contentAsBytes(), width: width, height: height); + } else if (mimeType.startsWith('text/')) { + return new Text(uri.data.contentAsString()); + } + return const SizedBox(); + } + + Widget _buildBullet(String listTag) { + if (listTag == 'ul') + return new Text('•', textAlign: TextAlign.center, style: styleSheet.styles['li']); + + final int index = _blocks.last.nextListIndex; + return new Padding( + padding: const EdgeInsets.only(right: 5.0), + child: new Text('${index + 1}.', textAlign: TextAlign.right, style: styleSheet.styles['li']), + ); + } + + 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) + parent.children.add(new SizedBox(height: styleSheet.blockSpacing)); + parent.children.add(child); + parent.nextListIndex += 1; + } + + void _addAnonymousBlockIfNeeded(TextStyle style) { + if (_inlines.isEmpty) { + return; + } + + final _InlineElement inline = _inlines.single; + if (inline.children.isNotEmpty) { + List mergedInlines = _mergeInlineChildren(inline); + final Wrap wrap = new Wrap(children: mergedInlines); + _addBlockChild(wrap); + _inlines.clear(); + } + } + + /// Merges adjacent [TextSpan] children of the given [_InlineElement] + List _mergeInlineChildren(_InlineElement inline) { + List mergedTexts = []; + for (Widget child in inline.children) { + if (mergedTexts.isNotEmpty && mergedTexts.last is RichText && child is RichText) { + RichText previous = mergedTexts.removeLast(); + List 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/lib/src/style_sheet.dart b/packages/flutter_markdown/lib/src/style_sheet.dart new file mode 100644 index 000000000000..53e38db666f1 --- /dev/null +++ b/packages/flutter_markdown/lib/src/style_sheet.dart @@ -0,0 +1,299 @@ +// 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'; + +/// Defines which [TextStyle] objects to use for which Markdown elements. +class MarkdownStyleSheet { + /// Creates an explicit mapping of [TextStyle] objects to Markdown elements. + MarkdownStyleSheet({ + 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.img, + this.blockSpacing, + this.listIndent, + this.blockquotePadding, + this.blockquoteDecoration, + this.codeblockPadding, + this.codeblockDecoration, + this.horizontalRuleDecoration + }) : _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, + 'img': img, + }; + + /// Creates a [MarkdownStyleSheet] from the [TextStyle]s in the provided [ThemeData]. + factory MarkdownStyleSheet.fromTheme(ThemeData theme) { + assert(theme?.textTheme?.body1?.fontSize != null); + return new MarkdownStyleSheet( + a: const TextStyle(color: Colors.blue), + p: theme.textTheme.body1, + code: new TextStyle( + color: Colors.grey.shade700, + fontFamily: "monospace", + fontSize: theme.textTheme.body1.fontSize * 0.85 + ), + h1: theme.textTheme.headline, + h2: theme.textTheme.title, + h3: theme.textTheme.subhead, + h4: theme.textTheme.body2, + h5: theme.textTheme.body2, + h6: theme.textTheme.body2, + em: const TextStyle(fontStyle: FontStyle.italic), + strong: const TextStyle(fontWeight: FontWeight.bold), + blockquote: theme.textTheme.body1, + img: theme.textTheme.body1, + blockSpacing: 8.0, + listIndent: 32.0, + blockquotePadding: 8.0, + blockquoteDecoration: new BoxDecoration( + color: Colors.blue.shade100, + borderRadius: new BorderRadius.circular(2.0) + ), + codeblockPadding: 8.0, + codeblockDecoration: new BoxDecoration( + color: Colors.grey.shade100, + borderRadius: new BorderRadius.circular(2.0) + ), + horizontalRuleDecoration: new BoxDecoration( + border: new Border( + top: new BorderSide(width: 5.0, color: Colors.grey.shade300) + ), + ), + ); + } + + /// Creates a [MarkdownStyle] from the [TextStyle]s in the provided [ThemeData]. + /// + /// This constructor uses larger fonts for the headings than in + /// [MarkdownStyle.fromTheme]. + factory MarkdownStyleSheet.largeFromTheme(ThemeData theme) { + return new MarkdownStyleSheet( + a: const TextStyle(color: Colors.blue), + p: theme.textTheme.body1, + code: new TextStyle( + color: Colors.grey.shade700, + fontFamily: "monospace", + fontSize: theme.textTheme.body1.fontSize * 0.85 + ), + h1: theme.textTheme.display3, + h2: theme.textTheme.display2, + h3: theme.textTheme.display1, + h4: theme.textTheme.headline, + h5: theme.textTheme.title, + h6: theme.textTheme.subhead, + em: const TextStyle(fontStyle: FontStyle.italic), + strong: const TextStyle(fontWeight: FontWeight.bold), + blockquote: theme.textTheme.body1, + img: theme.textTheme.body1, + blockSpacing: 8.0, + listIndent: 32.0, + blockquotePadding: 8.0, + blockquoteDecoration: new BoxDecoration( + color: Colors.blue.shade100, + borderRadius: new BorderRadius.circular(2.0) + ), + codeblockPadding: 8.0, + codeblockDecoration: new BoxDecoration( + color: Colors.grey.shade100, + borderRadius: new BorderRadius.circular(2.0) + ), + horizontalRuleDecoration: new BoxDecoration( + border: new Border( + top: new BorderSide(width: 5.0, color: Colors.grey.shade300) + ), + ), + ); + } + + /// Creates a new [MarkdownStyleSheet] based on the current style, with the + /// provided parameters overridden. + MarkdownStyleSheet 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, + TextStyle img, + double blockSpacing, + double listIndent, + double blockquotePadding, + Decoration blockquoteDecoration, + double codeblockPadding, + Decoration codeblockDecoration, + Decoration horizontalRuleDecoration + }) { + return new MarkdownStyleSheet( + a: a ?? this.a, + p: p ?? this.p, + code: code ?? this.code, + h1: h1 ?? this.h1, + h2: h2 ?? this.h2, + h3: h3 ?? this.h3, + h4: h4 ?? this.h4, + h5: h5 ?? this.h5, + h6: h6 ?? this.h6, + em: em ?? this.em, + strong: strong ?? this.strong, + blockquote: blockquote ?? this.blockquote, + img: img ?? this.img, + blockSpacing: blockSpacing ?? this.blockSpacing, + listIndent: listIndent ?? this.listIndent, + blockquotePadding: blockquotePadding ?? this.blockquotePadding, + blockquoteDecoration: blockquoteDecoration ?? this.blockquoteDecoration, + codeblockPadding: codeblockPadding ?? this.codeblockPadding, + codeblockDecoration: codeblockDecoration ?? this.codeblockDecoration, + horizontalRuleDecoration: horizontalRuleDecoration ?? this.horizontalRuleDecoration, + ); + } + + /// The [TextStyle] to use for `a` elements. + final TextStyle a; + + /// The [TextStyle] to use for `p` elements. + final TextStyle p; + + /// The [TextStyle] to use for `code` elements. + final TextStyle code; + + /// The [TextStyle] to use for `h1` elements. + final TextStyle h1; + + /// The [TextStyle] to use for `h2` elements. + final TextStyle h2; + + /// The [TextStyle] to use for `h3` elements. + final TextStyle h3; + + /// The [TextStyle] to use for `h4` elements. + final TextStyle h4; + + /// The [TextStyle] to use for `h5` elements. + final TextStyle h5; + + /// The [TextStyle] to use for `h6` elements. + final TextStyle h6; + + /// The [TextStyle] to use for `em` elements. + final TextStyle em; + + /// The [TextStyle] to use for `strong` elements. + final TextStyle strong; + + /// The [TextStyle] to use for `blockquote` elements. + final TextStyle blockquote; + + /// The [TextStyle] to use for `img` elements. + final TextStyle img; + + /// 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 padding to use for `blockquote` elements. + final double blockquotePadding; + + /// The decoration to use behind `blockquote` elements. + final Decoration blockquoteDecoration; + + /// The padding to use for `pre` elements. + final double codeblockPadding; + + /// The decoration to use behind for `pre` elements. + final Decoration codeblockDecoration; + + /// The decoration to use for `hr` elements. + final Decoration horizontalRuleDecoration; + + /// A [Map] from element name to the corresponding [TextStyle] object. + Map get styles => _styles; + Map _styles; + + @override + bool operator ==(dynamic other) { + if (identical(this, other)) + return true; + if (other.runtimeType != MarkdownStyleSheet) + return false; + final MarkdownStyleSheet typedOther = other; + return typedOther.a == a + && typedOther.p == p + && typedOther.code == code + && typedOther.h1 == h1 + && typedOther.h2 == h2 + && typedOther.h3 == h3 + && typedOther.h4 == h4 + && typedOther.h5 == h5 + && typedOther.h6 == h6 + && typedOther.em == em + && typedOther.strong == strong + && typedOther.blockquote == blockquote + && typedOther.img == img + && typedOther.blockSpacing == blockSpacing + && typedOther.listIndent == listIndent + && typedOther.blockquotePadding == blockquotePadding + && typedOther.blockquoteDecoration == blockquoteDecoration + && typedOther.codeblockPadding == codeblockPadding + && typedOther.codeblockDecoration == codeblockDecoration + && typedOther.horizontalRuleDecoration == horizontalRuleDecoration; + } + + @override + int get hashCode { + return hashValues( + a, + p, + code, + h1, + h2, + h3, + h4, + h5, + h6, + em, + strong, + blockquote, + img, + blockSpacing, + listIndent, + blockquotePadding, + blockquoteDecoration, + codeblockPadding, + codeblockDecoration, + horizontalRuleDecoration, + ); + } +} diff --git a/packages/flutter_markdown/lib/src/widget.dart b/packages/flutter_markdown/lib/src/widget.dart new file mode 100644 index 000000000000..39a15c6b267e --- /dev/null +++ b/packages/flutter_markdown/lib/src/widget.dart @@ -0,0 +1,224 @@ +// 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 'dart:io'; + +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:markdown/markdown.dart' as md; +import 'package:meta/meta.dart'; + +import 'builder.dart'; +import 'style_sheet.dart'; + +/// Signature for callbacks used by [MarkdownWidget] when the user taps a link. +/// +/// Used by [MarkdownWidget.onTapLink]. +typedef void MarkdownTapLinkCallback(String href); + +/// Creates a format [TextSpan] given a string. +/// +/// Used by [MarkdownWidget] to highlight the contents of `pre` elements. +abstract class SyntaxHighlighter { // ignore: one_member_abstracts + /// Returns the formated [TextSpan] for the given string. + TextSpan format(String source); +} + +/// A base class for widgets that parse and display Markdown. +/// +/// Supports all standard Markdown from the original +/// [Markdown specification](https://daringfireball.net/projects/markdown/). +/// +/// See also: +/// +/// * [Markdown], which is a scrolling container of Markdown. +/// * [MarkdownBody], which is a non-scrolling container of Markdown. +/// * +abstract class MarkdownWidget extends StatefulWidget { + /// Creates a widget that parses and displays Markdown. + /// + /// The [data] argument must not be null. + const MarkdownWidget({ + Key key, + @required this.data, + this.styleSheet, + this.syntaxHighlighter, + this.onTapLink, + this.imageDirectory, + }) : assert(data != null), + super(key: key); + + /// The Markdown to display. + final String data; + + /// The styles to use when displaying the Markdown. + /// + /// If null, the styles are inferred from the current [Theme]. + final MarkdownStyleSheet styleSheet; + + /// The syntax highlighter used to color text in `pre` elements. + /// + /// If null, the [MarkdownStyleSheet.code] style is used for `pre` elements. + final SyntaxHighlighter syntaxHighlighter; + + /// 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; + + /// Subclasses should override this function to display the given children, + /// which are the parsed representation of [data]. + @protected + Widget build(BuildContext context, List children); + + @override + _MarkdownWidgetState createState() => new _MarkdownWidgetState(); +} + +class _MarkdownWidgetState extends State implements MarkdownBuilderDelegate { + List _children; + final List _recognizers = []; + + @override + void didChangeDependencies() { + _parseMarkdown(); + super.didChangeDependencies(); + } + + @override + void didUpdateWidget(MarkdownWidget oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.data != oldWidget.data + || widget.styleSheet != oldWidget.styleSheet) + _parseMarkdown(); + } + + @override + void dispose() { + _disposeRecognizers(); + super.dispose(); + } + + void _parseMarkdown() { + final MarkdownStyleSheet styleSheet = widget.styleSheet ?? new MarkdownStyleSheet.fromTheme(Theme.of(context)); + + _disposeRecognizers(); + + // TODO: This can be optimized by doing the split and removing \r at the same time + final List lines = widget.data.replaceAll('\r\n', '\n').split('\n'); + final md.Document document = new md.Document(encodeHtml: false); + final MarkdownBuilder builder = new MarkdownBuilder( + delegate: this, + styleSheet: styleSheet, + imageDirectory: widget.imageDirectory, + ); + _children = builder.build(document.parseLines(lines)); + } + + void _disposeRecognizers() { + if (_recognizers.isEmpty) + return; + final List localRecognizers = new List.from(_recognizers); + _recognizers.clear(); + for (GestureRecognizer recognizer in localRecognizers) + recognizer.dispose(); + } + + @override + GestureRecognizer createLink(String href) { + final TapGestureRecognizer recognizer = new TapGestureRecognizer() + ..onTap = () { + if (widget.onTapLink != null) + widget.onTapLink(href); + }; + _recognizers.add(recognizer); + return recognizer; + } + + @override + TextSpan formatText(MarkdownStyleSheet styleSheet, String code) { + if (widget.syntaxHighlighter != null) + return widget.syntaxHighlighter.format(code); + return new TextSpan(style: styleSheet.code, text: code); + } + + @override + Widget build(BuildContext context) => widget.build(context, _children); +} + +/// A non-scrolling widget that parses and displays Markdown. +/// +/// Supports all standard Markdown from the original +/// [Markdown specification](https://daringfireball.net/projects/markdown/). +/// +/// See also: +/// +/// * [Markdown], which is a scrolling container of Markdown. +/// * +class MarkdownBody extends MarkdownWidget { + /// Creates a non-scrolling widget that parses and displays Markdown. + const MarkdownBody({ + Key key, + String data, + MarkdownStyleSheet styleSheet, + SyntaxHighlighter syntaxHighlighter, + MarkdownTapLinkCallback onTapLink, + Directory imageDirectory, + }) : super( + key: key, + data: data, + styleSheet: styleSheet, + syntaxHighlighter: syntaxHighlighter, + onTapLink: onTapLink, + imageDirectory: imageDirectory, + ); + + @override + Widget build(BuildContext context, List children) { + if (children.length == 1) + return children.single; + return new Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: children, + ); + } +} + +/// A scrolling widget that parses and displays Markdown. +/// +/// Supports all standard Markdown from the original +/// [Markdown specification](https://daringfireball.net/projects/markdown/). +/// +/// See also: +/// +/// * [MarkdownBody], which is a non-scrolling container of Markdown. +/// * +class Markdown extends MarkdownWidget { + /// Creates a scrolling widget that parses and displays Markdown. + const Markdown({ + Key key, + String data, + MarkdownStyleSheet styleSheet, + SyntaxHighlighter syntaxHighlighter, + MarkdownTapLinkCallback onTapLink, + Directory imageDirectory, + this.padding: const EdgeInsets.all(16.0), + }) : super( + key: key, + data: data, + styleSheet: styleSheet, + syntaxHighlighter: syntaxHighlighter, + onTapLink: onTapLink, + imageDirectory: imageDirectory, + ); + + /// The amount of space by which to inset the children. + final EdgeInsets padding; + + @override + Widget build(BuildContext context, List children) { + return new ListView(padding: padding, children: children); + } +} diff --git a/packages/flutter_markdown/pubspec.yaml b/packages/flutter_markdown/pubspec.yaml new file mode 100644 index 000000000000..d4e656528a14 --- /dev/null +++ b/packages/flutter_markdown/pubspec.yaml @@ -0,0 +1,21 @@ +name: flutter_markdown +author: Flutter Authors +description: A Markdown renderer for Flutter. +homepage: https://github.com/flutter/flutter_markdown +version: 0.2.0 + +dependencies: + flutter: + sdk: flutter + markdown: ^2.0.0 + meta: ^1.0.5 + string_scanner: ^1.0.0 + path: ^1.5.1 + +dev_dependencies: + flutter_test: + sdk: flutter + mockito: ^3.0.0 + +environment: + sdk: '>=2.0.0-dev.58.0 <3.0.0' 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 000000000000..ca341f622d03 --- /dev/null +++ b/packages/flutter_markdown/test/flutter_markdown_test.dart @@ -0,0 +1,538 @@ +// 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 'dart:async'; +import 'dart:io' as io; + +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:flutter/material.dart'; +import 'package:mockito/mockito.dart'; + +void main() { + TextTheme textTheme = new Typography(platform: TargetPlatform.android) + .black + .merge(new TextTheme(body1: new TextStyle(fontSize: 12.0))); + + testWidgets('Simple string', (WidgetTester tester) async { + await tester.pumpWidget(_boilerplate(const MarkdownBody(data: 'Hello'))); + + final Iterable widgets = tester.allWidgets; + _expectWidgetTypes( + widgets, [Directionality, MarkdownBody, Column, Wrap, RichText]); + _expectTextStrings(widgets, ['Hello']); + }); + + testWidgets('Header', (WidgetTester tester) async { + await tester.pumpWidget(_boilerplate(const MarkdownBody(data: '# Header'))); + + final Iterable widgets = tester.allWidgets; + _expectWidgetTypes( + widgets, [Directionality, MarkdownBody, Column, Wrap, RichText]); + _expectTextStrings(widgets, ['Header']); + }); + + testWidgets('Empty string', (WidgetTester tester) async { + await tester.pumpWidget(_boilerplate(const MarkdownBody(data: ''))); + + final Iterable widgets = tester.allWidgets; + _expectWidgetTypes(widgets, [Directionality, MarkdownBody, Column]); + }); + + testWidgets('Ordered list', (WidgetTester tester) async { + await tester.pumpWidget(_boilerplate( + const MarkdownBody(data: '1. Item 1\n1. Item 2\n2. Item 3'), + )); + + final Iterable widgets = tester.allWidgets; + _expectTextStrings(widgets, [ + '1.', + 'Item 1', + '2.', + 'Item 2', + '3.', + 'Item 3', + ]); + }); + + testWidgets('Unordered list', (WidgetTester tester) async { + await tester.pumpWidget( + _boilerplate(const MarkdownBody(data: '- Item 1\n- Item 2\n- Item 3')), + ); + + final Iterable widgets = tester.allWidgets; + _expectTextStrings(widgets, [ + '•', + 'Item 1', + '•', + 'Item 2', + '•', + 'Item 3', + ]); + }); + + testWidgets('Horizontal Rule', (WidgetTester tester) async { + await tester.pumpWidget(_boilerplate(const MarkdownBody(data: '-----'))); + + final Iterable widgets = tester.allWidgets; + _expectWidgetTypes( + widgets, [Directionality, MarkdownBody, DecoratedBox, SizedBox]); + }); + + testWidgets('Scrollable wrapping', (WidgetTester tester) async { + await tester.pumpWidget(_boilerplate(const Markdown(data: ''))); + + final List widgets = tester.allWidgets.toList(); + _expectWidgetTypes(widgets.take(3), [ + Directionality, + Markdown, + ListView, + ]); + _expectWidgetTypes(widgets.reversed.take(2).toList().reversed, [ + SliverPadding, + SliverList, + ]); + }); + + group('Links', () { + testWidgets('should be tappable', (WidgetTester tester) async { + String tapResult; + await tester.pumpWidget(_boilerplate(new Markdown( + data: '[Link Text](href)', + onTapLink: (value) => tapResult = value, + ))); + + final RichText textWidget = + tester.allWidgets.firstWhere((Widget widget) => widget is RichText); + final TextSpan span = textWidget.text; + + (span.recognizer as TapGestureRecognizer).onTap(); + + expect(span.children, null); + expect(span.recognizer.runtimeType, equals(TapGestureRecognizer)); + expect(tapResult, 'href'); + }); + + testWidgets('should work with nested elements', (WidgetTester tester) async { + final List tapResults = []; + await tester.pumpWidget(_boilerplate(new Markdown( + data: '[Link `with nested code` Text](href)', + onTapLink: (value) => tapResults.add(value), + ))); + + final RichText textWidget = + tester.allWidgets.firstWhere((Widget widget) => widget is RichText); + final TextSpan span = textWidget.text; + + final List gestureRecognizerTypes = []; + span.visitTextSpan((TextSpan textSpan) { + TapGestureRecognizer recognizer = textSpan.recognizer; + gestureRecognizerTypes.add(recognizer.runtimeType); + recognizer.onTap(); + return true; + }); + + expect(span.children.length, 3); + expect(gestureRecognizerTypes.length, 3); + expect(gestureRecognizerTypes, everyElement(TapGestureRecognizer)); + expect(tapResults.length, 3); + expect(tapResults, everyElement('href')); + }); + + testWidgets('should work next to other links', (WidgetTester tester) async { + final List tapResults = []; + + await tester.pumpWidget(_boilerplate(new Markdown( + data: '[First Link](firstHref) and [Second Link](secondHref)', + onTapLink: (value) => tapResults.add(value), + ))); + + final RichText textWidget = + tester.allWidgets.firstWhere((Widget widget) => widget is RichText); + final TextSpan span = textWidget.text; + + final List gestureRecognizerTypes = []; + span.visitTextSpan((TextSpan textSpan) { + TapGestureRecognizer recognizer = textSpan.recognizer; + gestureRecognizerTypes.add(recognizer.runtimeType); + recognizer?.onTap(); + return true; + }); + + expect(span.children.length, 3); + expect(gestureRecognizerTypes, + orderedEquals([TapGestureRecognizer, Null, TapGestureRecognizer])); + expect(tapResults, orderedEquals(['firstHref', 'secondHref'])); + }); + }); + + group('Images', () { + setUp(() { + // Only needs to be done once since the HttpClient generated by this + // override is cached as a static singleton. + io.HttpOverrides.global = new TestHttpOverrides(); + }); + + testWidgets('should not interrupt styling', (WidgetTester tester) async { + await tester.pumpWidget(_boilerplate(const Markdown( + data:'_textbefore ![alt](http://img) 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,'http://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: '![alt](https://img#50x50)'))); + + final Image image = + tester.allWidgets.firstWhere((Widget widget) => widget is Image); + final NetworkImage networkImage = image.image; + expect(networkImage.url, 'https://img'); + expect(image.width, 50); + expect(image.height, 50); + }); + + testWidgets('local files should be files', (WidgetTester tester) async { + await tester + .pumpWidget(_boilerplate(const Markdown(data: '![alt](http.png)'))); + + final Image image = + tester.allWidgets.firstWhere((Widget widget) => widget is Image); + expect(image.image is FileImage, isTrue); + }); + + testWidgets('should work with local image files', (WidgetTester tester) async { + await tester + .pumpWidget(_boilerplate(const Markdown(data: '![alt](img.png#50x50)'))); + + final Image image = + tester.allWidgets.firstWhere((Widget widget) => widget is Image); + final FileImage fileImage = image.image; + expect(fileImage.file.path, 'img.png'); + expect(image.width, 50); + expect(image.height, 50); + }); + + testWidgets('should show properly next to text', (WidgetTester tester) async { + await tester + .pumpWidget(_boilerplate(const Markdown(data: 'Hello ![alt](img#50x50)'))); + + final RichText richText = + tester.allWidgets.firstWhere((Widget widget) => widget is RichText); + TextSpan textSpan = richText.text; + expect(textSpan.text, 'Hello '); + expect(textSpan.style, isNotNull); + }); + + testWidgets('should work when nested in a link', (WidgetTester tester) async { + final List tapResults = []; + await tester.pumpWidget(_boilerplate(new Markdown( + data: '[![alt](https://img#50x50)](href)', + onTapLink: (value) => tapResults.add(value), + ))); + + final GestureDetector detector = + tester.allWidgets.firstWhere((Widget widget) => widget is GestureDetector); + + detector.onTap(); + + expect(tapResults.length, 1); + expect(tapResults, everyElement('href')); + }); + + testWidgets('should work when nested in a link with text', (WidgetTester tester) async { + final List tapResults = []; + await tester.pumpWidget(_boilerplate(new Markdown( + data: '[Text before ![alt](https://img#50x50) text after](href)', + onTapLink: (value) => tapResults.add(value), + ))); + + final GestureDetector detector = + tester.allWidgets.firstWhere((Widget widget) => widget is GestureDetector); + detector.onTap(); + + final RichText firstTextWidget = + tester.allWidgets.firstWhere((Widget widget) => widget is RichText); + final TextSpan firstSpan = firstTextWidget.text; + (firstSpan.recognizer as TapGestureRecognizer).onTap(); + + final RichText lastTextWidget = + tester.allWidgets.lastWhere((Widget widget) => widget is RichText); + final TextSpan lastSpan = lastTextWidget.text; + (lastSpan.recognizer as TapGestureRecognizer).onTap(); + + expect(firstSpan.children, null); + expect(firstSpan.text, 'Text before '); + expect(firstSpan.recognizer.runtimeType, equals(TapGestureRecognizer)); + + expect(lastSpan.children, null); + expect(lastSpan.text, ' text after'); + expect(lastSpan.recognizer.runtimeType, equals(TapGestureRecognizer)); + + expect(tapResults.length, 3); + expect(tapResults, everyElement('href')); + }); + + testWidgets('should work alongside different links', (WidgetTester tester) async { + final List tapResults = []; + await tester.pumpWidget(_boilerplate(new Markdown( + data: '[Link before](firstHref)[![alt](https://img#50x50)](imageHref)[link after](secondHref)', + onTapLink: (value) => tapResults.add(value), + ))); + + final RichText firstTextWidget = + tester.allWidgets.firstWhere((Widget widget) => widget is RichText); + final TextSpan firstSpan = firstTextWidget.text; + (firstSpan.recognizer as TapGestureRecognizer).onTap(); + + final GestureDetector detector = + tester.allWidgets.firstWhere((Widget widget) => widget is GestureDetector); + detector.onTap(); + + final RichText lastTextWidget = + tester.allWidgets.lastWhere((Widget widget) => widget is RichText); + final TextSpan lastSpan = lastTextWidget.text; + (lastSpan.recognizer as TapGestureRecognizer).onTap(); + + expect(firstSpan.children, null); + expect(firstSpan.text, 'Link before'); + expect(firstSpan.recognizer.runtimeType, equals(TapGestureRecognizer)); + + expect(lastSpan.children, null); + expect(lastSpan.text, 'link after'); + expect(lastSpan.recognizer.runtimeType, equals(TapGestureRecognizer)); + + expect(tapResults.length, 3); + expect(tapResults, ['firstHref', 'imageHref', 'secondHref']); + }); + }); + + group('uri data scheme', () { + testWidgets('should work with image in uri data scheme', (WidgetTester tester) async { + const String imageData = '![alt](data:image/gif;base64,R0lGODlhAQABAIAAAAUEBAAAACwAAAAAAQABAAACAkQBADs=)'; + await tester + .pumpWidget(_boilerplate(const Markdown(data: imageData))); + + final Image image = + tester.allWidgets.firstWhere((Widget widget) => widget is Image); + expect(image.image.runtimeType, MemoryImage); + }); + + testWidgets('should work with base64 text in uri data scheme', (WidgetTester tester) async { + const String imageData = '![alt](data:text/plan;base64,Rmx1dHRlcg==)'; + await tester + .pumpWidget(_boilerplate(const Markdown(data: imageData))); + + final Text widget = + tester.allWidgets.firstWhere((Widget widget) => widget is Text); + expect(widget.runtimeType, Text); + expect(widget.data, 'Flutter'); + }); + + testWidgets('should work with text in uri data scheme', (WidgetTester tester) async { + const String imageData = '![alt](data:text/plan,Hello%2C%20Flutter)'; + await tester + .pumpWidget(_boilerplate(const Markdown(data: imageData))); + + final Text widget = + tester.allWidgets.firstWhere((Widget widget) => widget is Text); + expect(widget.runtimeType, Text); + expect(widget.data, 'Hello, Flutter'); + }); + + testWidgets('should work with empty uri data scheme', (WidgetTester tester) async { + const String imageData = '![alt](data:,)'; + await tester + .pumpWidget(_boilerplate(const Markdown(data: imageData))); + + final Text widget = + tester.allWidgets.firstWhere((Widget widget) => widget is Text); + expect(widget.runtimeType, Text); + expect(widget.data, ''); + }); + + testWidgets('should work with unsupported mime types of uri data scheme', (WidgetTester tester) async { + const String imageData = '![alt](data:application/javascript,var%20test=1)'; + await tester + .pumpWidget(_boilerplate(const Markdown(data: imageData))); + + final SizedBox widget = + tester.allWidgets.firstWhere((Widget widget) => widget is SizedBox); + expect(widget.runtimeType, SizedBox); + }); + }); + + testWidgets('HTML tag ignored ', (WidgetTester tester) async { + final List mdData = [ + 'Line 1\n

HTML content

\nLine 2', + 'Line 1\n<\nLine 2' + ]; + + for (String mdLine in mdData) { + await tester.pumpWidget(_boilerplate(new MarkdownBody(data: mdLine))); + + final Iterable widgets = tester.allWidgets; + _expectTextStrings(widgets, ['Line 1', 'Line 2']); + } + }); + + group('Parser does not convert', () { + testWidgets('& to & when parsing', (WidgetTester tester) async { + await tester.pumpWidget(_boilerplate(const Markdown(data: '&'))); + _expectTextStrings(tester.allWidgets, ['&']); + }); + + testWidgets('< to < when parsing', (WidgetTester tester) async { + await tester.pumpWidget(_boilerplate(const Markdown(data: '<'))); + _expectTextStrings(tester.allWidgets, ['<']); + }); + + testWidgets('existing HTML entities when parsing', (WidgetTester tester) async { + await tester.pumpWidget(_boilerplate(const Markdown(data: '& © < {'))); + _expectTextStrings(tester.allWidgets, ['& © < {']); + }); + }); + + testWidgets('Changing config - data', (WidgetTester tester) async { + await tester.pumpWidget(_boilerplate(const Markdown(data: 'Data1'))); + _expectTextStrings(tester.allWidgets, ['Data1']); + + final String stateBefore = _dumpRenderView(); + await tester.pumpWidget(_boilerplate(const Markdown(data: 'Data1'))); + final String stateAfter = _dumpRenderView(); + expect(stateBefore, equals(stateAfter)); + + await tester.pumpWidget(_boilerplate(const Markdown(data: 'Data2'))); + _expectTextStrings(tester.allWidgets, ['Data2']); + }); + + testWidgets('Changing config - style', (WidgetTester tester) async { + final ThemeData theme = new ThemeData.light().copyWith(textTheme: textTheme); + + final MarkdownStyleSheet style1 = new MarkdownStyleSheet.fromTheme(theme); + final MarkdownStyleSheet style2 = + new MarkdownStyleSheet.largeFromTheme(theme); + expect(style1, isNot(style2)); + + await tester.pumpWidget( + _boilerplate(new Markdown(data: '# Test', styleSheet: style1)), + ); + final RichText text1 = tester.widget(find.byType(RichText)); + await tester.pumpWidget( + _boilerplate(new Markdown(data: '# Test', styleSheet: style2)), + ); + final RichText text2 = tester.widget(find.byType(RichText)); + + expect(text1.text, isNot(text2.text)); + }); + + testWidgets('Style equality', (WidgetTester tester) async { + final ThemeData theme = new ThemeData.light().copyWith(textTheme: textTheme); + + final MarkdownStyleSheet style1 = new MarkdownStyleSheet.fromTheme(theme); + final MarkdownStyleSheet style2 = new MarkdownStyleSheet.fromTheme(theme); + expect(style1, equals(style2)); + expect(style1.hashCode, equals(style2.hashCode)); + }); +} + +void _expectWidgetTypes(Iterable widgets, List expected) { + final List actual = widgets.map((Widget w) => w.runtimeType).toList(); + expect(actual, expected); +} + +void _expectTextStrings(Iterable widgets, List strings) { + int currentString = 0; + for (Widget widget in widgets) { + if (widget is RichText) { + final TextSpan span = widget.text; + final 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; +} + +String _dumpRenderView() { + return WidgetsBinding.instance.renderViewElement.toStringDeep().replaceAll( + new RegExp(r'SliverChildListDelegate#\d+', multiLine: true), + 'SliverChildListDelegate'); +} + +/// Wraps a widget with a left-to-right [Directionality] for tests. +Widget _boilerplate(Widget child) { + return new Directionality( + textDirection: TextDirection.ltr, + child: child, + ); +} + +class MockHttpClient extends Mock implements io.HttpClient {} +class MockHttpClientRequest extends Mock implements io.HttpClientRequest {} +class MockHttpClientResponse extends Mock implements io.HttpClientResponse {} +class MockHttpHeaders extends Mock implements io.HttpHeaders {} + +class TestHttpOverrides extends io.HttpOverrides { + io.HttpClient createHttpClient(io.SecurityContext context) { + return createMockImageHttpClient(context); + } +} + +// Returns a mock HTTP client that responds with an image to all requests. +MockHttpClient createMockImageHttpClient(io.SecurityContext _) { + final MockHttpClient client = new MockHttpClient(); + final MockHttpClientRequest request = new MockHttpClientRequest(); + final MockHttpClientResponse response = new MockHttpClientResponse(); + final MockHttpHeaders headers = new MockHttpHeaders(); + + when(client.getUrl(any)).thenAnswer((_) => new Future.value(request)); + when(request.headers).thenReturn(headers); + when(request.close()).thenAnswer((_) => new Future.value(response)); + when(response.contentLength).thenReturn(_transparentImage.length); + when(response.statusCode).thenReturn(io.HttpStatus.ok); + when(response.listen(any)).thenAnswer((Invocation invocation) { + final void Function(List) onData = invocation.positionalArguments[0]; + final void Function() onDone = invocation.namedArguments[#onDone]; + final void Function(Object, [StackTrace]) onError = invocation.namedArguments[#onError]; + final bool cancelOnError = invocation.namedArguments[#cancelOnError]; + + return new Stream>.fromIterable(>[_transparentImage]) + .listen(onData, onDone: onDone, onError: onError, cancelOnError: cancelOnError); + }); + + return client; +} + +const List _transparentImage = const [ + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, + 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, + 0x00, 0x00, 0x00, 0x1F, 0x15, 0xC4, 0x89, 0x00, 0x00, 0x00, 0x0A, 0x49, 0x44, + 0x41, 0x54, 0x78, 0x9C, 0x63, 0x00, 0x01, 0x00, 0x00, 0x05, 0x00, 0x01, 0x0D, + 0x0A, 0x2D, 0xB4, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, +];