434 lines
13 KiB
Dart
434 lines
13 KiB
Dart
// 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<MarkdownBodyRaw> {
|
|
|
|
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<Widget> blocks = <Widget>[];
|
|
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<String> lines = data.replaceAll('\r\n', '\n').split('\n');
|
|
md.Document document = new md.Document();
|
|
|
|
_Renderer renderer = new _Renderer();
|
|
return renderer.render(document.parseLines(lines), markdownStyle, syntaxHighlighter);
|
|
}
|
|
|
|
class _Renderer implements md.NodeVisitor {
|
|
List<_Block> render(List<md.Node> nodes, MarkdownStyleRaw markdownStyle, SyntaxHighlighter syntaxHighlighter) {
|
|
assert(markdownStyle != null);
|
|
|
|
_blocks = <_Block>[];
|
|
_listIndents = <String>[];
|
|
_markdownStyle = markdownStyle;
|
|
_syntaxHighlighter = syntaxHighlighter;
|
|
|
|
for (final md.Node node in nodes) {
|
|
node.accept(this);
|
|
}
|
|
|
|
return _blocks;
|
|
}
|
|
|
|
List<_Block> _blocks;
|
|
List<String> _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<String>.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<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);
|
|
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<String, String> attributes;
|
|
final MarkdownStyleRaw markdownStyle;
|
|
final List<String> 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<Widget> subWidgets = <Widget>[];
|
|
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: <Widget>[
|
|
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<TextSpan> children = <TextSpan>[];
|
|
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<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 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: <TextSpan>[new TextSpan(text: source)]);
|
|
}
|
|
}
|