blob: fcdc50ce1a23ca1d9b90284925fb84d696cd6c96 [file] [log] [blame]
// 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 '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:
/// 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 StatelessWidget {
/// 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 EdgeInsets.all(16.0),
this.onTapLink
});
/// 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 EdgeInsets padding;
/// Callback when a link is tapped
final MarkdownLinkCallback onTapLink;
@override
Widget build(BuildContext context) {
// TODO(abarth): We should use a ListView here and lazily build the widgets
// from the markdown.
return new SingleChildScrollView(
padding: padding,
child: createMarkdownBody(
data: data,
markdownStyle: markdownStyle,
syntaxHighlighter: syntaxHighlighter,
onTapLink: onTapLink,
),
);
}
MarkdownBodyRaw createMarkdownBody({
String data,
covariant MarkdownStyleRaw markdownStyle,
SyntaxHighlighter syntaxHighlighter,
MarkdownLinkCallback onTapLink
}) {
return new MarkdownBodyRaw(
data: data,
markdownStyle: markdownStyle,
syntaxHighlighter: syntaxHighlighter,
onTapLink: onTapLink
);
}
}
/// 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 [SingleChildScrollView] or use the [MarkdownRaw]
/// widget.
class MarkdownBodyRaw extends StatefulWidget {
/// 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
/// a [SingleChildScrollView], or use the [Markdown class].
///
/// ```dart
/// new SingleChildScrollView(
/// padding: new EdgeInsets.all(16.0),
/// child: new MarkdownBodyRaw(
/// data: markdownSource,
/// markdownStyle: myStyle,
/// ),
/// ),
/// ```
MarkdownBodyRaw({
this.data,
this.markdownStyle,
this.syntaxHighlighter,
this.onTapLink
});
/// 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;
/// Callback when a link is tapped
final MarkdownLinkCallback onTapLink;
@override
_MarkdownBodyRawState createState() => new _MarkdownBodyRawState();
MarkdownStyleRaw createDefaultStyle(BuildContext context) => null;
}
class _MarkdownBodyRawState extends State<MarkdownBodyRaw> {
@override
void dependenciesChanged() {
_buildMarkdownCache();
super.dependenciesChanged();
}
@override
void dispose() {
_linkHandler.dispose();
super.dispose();
}
@override
void didUpdateConfig(MarkdownBodyRaw oldConfig) {
super.didUpdateConfig(oldConfig);
if (oldConfig.data != config.data ||
oldConfig.markdownStyle != config.markdownStyle ||
oldConfig.syntaxHighlighter != config.syntaxHighlighter ||
oldConfig.onTapLink != config.onTapLink)
_buildMarkdownCache();
}
void _buildMarkdownCache() {
MarkdownStyleRaw markdownStyle = config.markdownStyle ?? config.createDefaultStyle(context);
SyntaxHighlighter syntaxHighlighter = config.syntaxHighlighter ?? new _DefaultSyntaxHighlighter(markdownStyle.code);
_linkHandler?.dispose();
_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);
}
List<_Block> _cachedBlocks;
_LinkHandler _linkHandler;
@override
Widget build(BuildContext context) {
List<Widget> blocks = <Widget>[];
for (_Block block in _cachedBlocks) {
blocks.add(block.build(context));
}
return new Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: blocks
);
}
@override
void debugFillDescription(List<String> description) {
super.debugFillDescription(description);
description.add('cached blocks identity: ${_cachedBlocks.hashCode}');
}
}
class _Renderer implements md.NodeVisitor {
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);
}
return _blocks;
}
List<_Block> _blocks;
List<String> _listIndents;
MarkdownStyleRaw _markdownStyle;
SyntaxHighlighter _syntaxHighlighter;
_LinkHandler _linkHandler;
@override
void visitText(md.Text text) {
if (_currentBlock != null) { // ignore if no corresponding block
_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));
}
}
@override
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<String>.from(_listIndents), blockList.length);
blockList.add(newBlock);
} else {
_LinkInfo linkInfo;
if (element.tag == 'a') {
linkInfo = _linkHandler.createLinkInfo(element.attributes['href']);
}
TextStyle style = _markdownStyle.styles[element.tag] ?? const TextStyle();
List<_MarkdownNode> styleElement = <_MarkdownNode>[new _MarkdownNodeTextStyle(style, linkInfo)];
_currentBlock.stack.add(new _MarkdownNodeList(styleElement));
}
return true;
}
@override
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<String> _kBlockTags = const <String>['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'li', 'blockquote', 'img', 'pre', 'ol', 'ul'];
static const List<String> _kListTags = const <String>['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, [this.linkInfo = null]);
TextStyle style;
_LinkInfo linkInfo;
}
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 = const TextStyle(color: const Color(0xffff0000));
stack = <_MarkdownNode>[new _MarkdownNodeList(<_MarkdownNode>[new _MarkdownNodeTextStyle(style)])];
subBlocks = <_Block>[];
}
final String tag;
final Map<String, String> attributes;
final MarkdownStyleRaw markdownStyle;
final List<String> listIndents;
final int blockPosition;
List<_MarkdownNode> stack;
List<_Block> subBlocks;
bool get open => _open;
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<Widget> subWidgets = <Widget>[];
for (_Block subBlock in subBlocks) {
subWidgets.add(subBlock.build(context));
}
contents = new Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: subWidgets
);
} else {
TextSpan span = _stackToTextSpan(new _MarkdownNodeList(stack));
contents = new RichText(text: span);
if (listIndents.length > 0) {
Widget bullet;
if (listIndents.last == 'ul') {
bullet = new Text(
'•',
textAlign: TextAlign.center
);
}
else {
bullet = new Padding(
padding: const EdgeInsets.only(right: 5.0),
child: new Text(
"${blockPosition + 1}.",
textAlign: TextAlign.right
)
);
}
contents = new Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
new SizedBox(
width: listIndents.length * markdownStyle.listIndent,
child: bullet
),
new Expanded(child: contents)
]
);
}
}
BoxDecoration decoration;
EdgeInsets padding;
if (tag == 'blockquote') {
decoration = markdownStyle.blockquoteDecoration;
padding = new EdgeInsets.all(markdownStyle.blockquotePadding);
} else if (tag == 'pre') {
decoration = markdownStyle.codeblockDecoration;
padding = new EdgeInsets.all(markdownStyle.codeblockPadding);
}
return new Container(
padding: padding,
margin: new EdgeInsets.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];
_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]));
}
String text;
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) {
return new TextSpan(text: stack.string);
}
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();
String path = parts.first;
double width;
double height;
if (parts.length == 2) {
List<String> dimensions = parts.last.split('x');
if (dimensions.length == 2) {
width = double.parse(dimensions[0]);
height = double.parse(dimensions[1]);
}
}
return new Image.network(path, width: width, height: height);
}
}
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 { // ignore: one_member_abstracts
TextSpan format(String source);
}
class _DefaultSyntaxHighlighter extends SyntaxHighlighter{
_DefaultSyntaxHighlighter(this.style);
final TextStyle style;
@override
TextSpan format(String source) {
return new TextSpan(style: style, children: <TextSpan>[new TextSpan(text: source)]);
}
}