blob: 17f74066f5d8112a40daa388821784b380a32461 [file] [log] [blame]
// Copyright 2013 The Flutter 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:convert';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:markdown/markdown.dart' as md;
import '../flutter_markdown.dart';
import '_functions_io.dart' if (dart.library.html) '_functions_web.dart';
/// Signature for callbacks used by [MarkdownWidget] when the user taps a link.
/// The callback will return the link text, destination, and title from the
/// Markdown link tag in the document.
///
/// Used by [MarkdownWidget.onTapLink].
typedef MarkdownTapLinkCallback = void Function(
String text, String? href, String title);
/// Signature for custom image widget.
///
/// Used by [MarkdownWidget.imageBuilder]
typedef MarkdownImageBuilder = Widget Function(
Uri uri, String? title, String? alt);
/// Signature for custom checkbox widget.
///
/// Used by [MarkdownWidget.checkboxBuilder]
typedef MarkdownCheckboxBuilder = Widget Function(bool value);
/// Signature for custom bullet widget.
///
/// Used by [MarkdownWidget.bulletBuilder]
typedef MarkdownBulletBuilder = Widget Function(int index, BulletStyle style);
/// Enumeration sent to the user when calling [MarkdownBulletBuilder]
///
/// Use this to differentiate the bullet styling when building your own.
enum BulletStyle {
/// An ordered list.
orderedList,
/// An unordered list.
unorderedList,
}
/// 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 formatted [TextSpan] for the given string.
TextSpan format(String source);
}
/// An interface for an element builder.
abstract class MarkdownElementBuilder {
/// Called when an Element has been reached, before its children have been
/// visited.
void visitElementBefore(md.Element element) {}
/// Called when a text node has been reached.
///
/// If [MarkdownWidget.styleSheet] has a style of this tag, will passing
/// to [preferredStyle].
///
/// If you needn't build a widget, return null.
Widget? visitText(md.Text text, TextStyle? preferredStyle) => null;
/// Called when an Element has been reached, after its children have been
/// visited.
///
/// If [MarkdownWidget.styleSheet] has a style of this tag, will passing
/// to [preferredStyle].
///
/// If you needn't build a widget, return null.
Widget? visitElementAfter(md.Element element, TextStyle? preferredStyle) =>
null;
}
/// Enum to specify which theme being used when creating [MarkdownStyleSheet]
///
/// [material] - create MarkdownStyleSheet based on MaterialTheme
/// [cupertino] - create MarkdownStyleSheet based on CupertinoTheme
/// [platform] - create MarkdownStyleSheet based on the Platform where the
/// is running on. Material on Android and Cupertino on iOS
enum MarkdownStyleSheetBaseTheme {
/// Creates a MarkdownStyleSheet based on MaterialTheme.
material,
/// Creates a MarkdownStyleSheet based on CupertinoTheme.
cupertino,
/// Creates a MarkdownStyleSheet whose theme is based on the current platform.
platform,
}
/// Enumeration of alignment strategies for the cross axis of list items.
enum MarkdownListItemCrossAxisAlignment {
/// Uses [CrossAxisAlignment.baseline] for the row the bullet and the list
/// item are placed in.
///
/// This alignment will ensure that the bullet always lines up with
/// the list text on the baseline.
///
/// However, note that this alignment does not support intrinsic height
/// measurements because [RenderFlex] does not support it for
/// [CrossAxisAlignment.baseline].
/// See https://github.com/flutter/flutter_markdown/issues/311 for cases,
/// where this might be a problem for you.
///
/// See also:
/// * [start], which allows for intrinsic height measurements.
baseline,
/// Uses [CrossAxisAlignment.start] for the row the bullet and the list item
/// are placed in.
///
/// This alignment will ensure that intrinsic height measurements work.
///
/// However, note that this alignment might not line up the bullet with the
/// list text in the way you would expect in certain scenarios.
/// See https://github.com/flutter/flutter_markdown/issues/169 for example
/// cases that do not produce expected results.
///
/// See also:
/// * [baseline], which will position the bullet and list item on the
/// baseline.
start,
}
/// A base class for widgets that parse and display Markdown.
///
/// Supports all standard Markdown from the original
/// [Markdown specification](https://github.github.com/gfm/).
///
/// See also:
///
/// * [Markdown], which is a scrolling container of Markdown.
/// * [MarkdownBody], which is a non-scrolling container of Markdown.
/// * <https://github.github.com/gfm/>
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.selectable = false,
this.styleSheet,
this.styleSheetTheme = MarkdownStyleSheetBaseTheme.material,
this.syntaxHighlighter,
this.onTapLink,
this.onTapText,
this.imageDirectory,
this.blockSyntaxes,
this.inlineSyntaxes,
this.extensionSet,
this.imageBuilder,
this.checkboxBuilder,
this.bulletBuilder,
this.builders = const <String, MarkdownElementBuilder>{},
this.paddingBuilders = const <String, MarkdownPaddingBuilder>{},
this.fitContent = false,
this.listItemCrossAxisAlignment =
MarkdownListItemCrossAxisAlignment.baseline,
this.softLineBreak = false,
}) : super(key: key);
/// The Markdown to display.
final String data;
/// If true, the text is selectable.
///
/// Defaults to false.
final bool selectable;
/// The styles to use when displaying the Markdown.
///
/// If null, the styles are inferred from the current [Theme].
final MarkdownStyleSheet? styleSheet;
/// Setting to specify base theme for MarkdownStyleSheet
///
/// Default to [MarkdownStyleSheetBaseTheme.material]
final MarkdownStyleSheetBaseTheme? styleSheetTheme;
/// 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;
/// Default tap handler used when [selectable] is set to true
final VoidCallback? onTapText;
/// The base directory holding images referenced by Img tags with local or network file paths.
final String? imageDirectory;
/// Collection of custom block syntax types to be used parsing the Markdown data.
final List<md.BlockSyntax>? blockSyntaxes;
/// Collection of custom inline syntax types to be used parsing the Markdown data.
final List<md.InlineSyntax>? inlineSyntaxes;
/// Markdown syntax extension set
///
/// Defaults to [md.ExtensionSet.gitHubFlavored]
final md.ExtensionSet? extensionSet;
/// Call when build an image widget.
final MarkdownImageBuilder? imageBuilder;
/// Call when build a checkbox widget.
final MarkdownCheckboxBuilder? checkboxBuilder;
/// Called when building a bullet
final MarkdownBulletBuilder? bulletBuilder;
/// Render certain tags, usually used with [extensionSet]
///
/// For example, we will add support for `sub` tag:
///
/// ```dart
/// builders: {
/// 'sub': SubscriptBuilder(),
/// }
/// ```
///
/// The `SubscriptBuilder` is a subclass of [MarkdownElementBuilder].
final Map<String, MarkdownElementBuilder> builders;
/// Add padding for different tags (use only for block elements and img)
///
/// For example, we will add padding for `img` tag:
///
/// ```dart
/// paddingBuilders: {
/// 'img': ImgPaddingBuilder(),
/// }
/// ```
///
/// The `ImgPaddingBuilder` is a subclass of [MarkdownPaddingBuilder].
final Map<String, MarkdownPaddingBuilder> paddingBuilders;
/// Whether to allow the widget to fit the child content.
final bool fitContent;
/// Controls the cross axis alignment for the bullet and list item content
/// in lists.
///
/// Defaults to [MarkdownListItemCrossAxisAlignment.baseline], which
/// does not allow for intrinsic height measurements.
final MarkdownListItemCrossAxisAlignment listItemCrossAxisAlignment;
/// The soft line break is used to identify the spaces at the end of aline of
/// text and the leading spaces in the immediately following the line of text.
///
/// Default these spaces are removed in accordance with the Markdown
/// specification on soft line breaks when lines of text are joined.
final bool softLineBreak;
/// Subclasses should override this function to display the given children,
/// which are the parsed representation of [data].
@protected
Widget build(BuildContext context, List<Widget>? children);
@override
State<MarkdownWidget> createState() => _MarkdownWidgetState();
}
class _MarkdownWidgetState extends State<MarkdownWidget>
implements MarkdownBuilderDelegate {
List<Widget>? _children;
final List<GestureRecognizer> _recognizers = <GestureRecognizer>[];
@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 fallbackStyleSheet =
kFallbackStyle(context, widget.styleSheetTheme);
final MarkdownStyleSheet styleSheet =
fallbackStyleSheet.merge(widget.styleSheet);
_disposeRecognizers();
final md.Document document = md.Document(
blockSyntaxes: widget.blockSyntaxes,
inlineSyntaxes: (widget.inlineSyntaxes ?? <md.InlineSyntax>[])
..add(TaskListSyntax()),
extensionSet: widget.extensionSet ?? md.ExtensionSet.gitHubFlavored,
encodeHtml: false,
);
// Parse the source Markdown data into nodes of an Abstract Syntax Tree.
final List<String> lines = const LineSplitter().convert(widget.data);
final List<md.Node> astNodes = document.parseLines(lines);
// Configure a Markdown widget builder to traverse the AST nodes and
// create a widget tree based on the elements.
final MarkdownBuilder builder = MarkdownBuilder(
delegate: this,
selectable: widget.selectable,
styleSheet: styleSheet,
imageDirectory: widget.imageDirectory,
imageBuilder: widget.imageBuilder,
checkboxBuilder: widget.checkboxBuilder,
bulletBuilder: widget.bulletBuilder,
builders: widget.builders,
paddingBuilders: widget.paddingBuilders,
fitContent: widget.fitContent,
listItemCrossAxisAlignment: widget.listItemCrossAxisAlignment,
onTapText: widget.onTapText,
softLineBreak: widget.softLineBreak,
);
_children = builder.build(astNodes);
}
void _disposeRecognizers() {
if (_recognizers.isEmpty) {
return;
}
final List<GestureRecognizer> localRecognizers =
List<GestureRecognizer>.from(_recognizers);
_recognizers.clear();
for (final GestureRecognizer recognizer in localRecognizers) {
recognizer.dispose();
}
}
@override
GestureRecognizer createLink(String text, String? href, String title) {
final TapGestureRecognizer recognizer = TapGestureRecognizer()
..onTap = () {
if (widget.onTapLink != null) {
widget.onTapLink!(text, href, title);
}
};
_recognizers.add(recognizer);
return recognizer;
}
@override
TextSpan formatText(MarkdownStyleSheet styleSheet, String code) {
code = code.replaceAll(RegExp(r'\n$'), '');
if (widget.syntaxHighlighter != null) {
return widget.syntaxHighlighter!.format(code);
}
return 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 GitHub Flavored Markdown from the
/// [specification](https://github.github.com/gfm/).
///
/// See also:
///
/// * [Markdown], which is a scrolling container of Markdown.
/// * <https://github.github.com/gfm/>
class MarkdownBody extends MarkdownWidget {
/// Creates a non-scrolling widget that parses and displays Markdown.
const MarkdownBody({
Key? key,
required String data,
bool selectable = false,
MarkdownStyleSheet? styleSheet,
MarkdownStyleSheetBaseTheme? styleSheetTheme,
SyntaxHighlighter? syntaxHighlighter,
MarkdownTapLinkCallback? onTapLink,
VoidCallback? onTapText,
String? imageDirectory,
List<md.BlockSyntax>? blockSyntaxes,
List<md.InlineSyntax>? inlineSyntaxes,
md.ExtensionSet? extensionSet,
MarkdownImageBuilder? imageBuilder,
MarkdownCheckboxBuilder? checkboxBuilder,
MarkdownBulletBuilder? bulletBuilder,
Map<String, MarkdownElementBuilder> builders =
const <String, MarkdownElementBuilder>{},
Map<String, MarkdownPaddingBuilder> paddingBuilders =
const <String, MarkdownPaddingBuilder>{},
MarkdownListItemCrossAxisAlignment listItemCrossAxisAlignment =
MarkdownListItemCrossAxisAlignment.baseline,
this.shrinkWrap = true,
bool fitContent = true,
bool softLineBreak = false,
}) : super(
key: key,
data: data,
selectable: selectable,
styleSheet: styleSheet,
styleSheetTheme: styleSheetTheme,
syntaxHighlighter: syntaxHighlighter,
onTapLink: onTapLink,
onTapText: onTapText,
imageDirectory: imageDirectory,
blockSyntaxes: blockSyntaxes,
inlineSyntaxes: inlineSyntaxes,
extensionSet: extensionSet,
imageBuilder: imageBuilder,
checkboxBuilder: checkboxBuilder,
builders: builders,
paddingBuilders: paddingBuilders,
listItemCrossAxisAlignment: listItemCrossAxisAlignment,
bulletBuilder: bulletBuilder,
fitContent: fitContent,
softLineBreak: softLineBreak,
);
/// If [shrinkWrap] is `true`, [MarkdownBody] will take the minimum height
/// that wraps its content. Otherwise, [MarkdownBody] will expand to the
/// maximum allowed height.
final bool shrinkWrap;
@override
Widget build(BuildContext context, List<Widget>? children) {
if (children!.length == 1 && shrinkWrap) {
return children.single;
}
return Column(
mainAxisSize: shrinkWrap ? MainAxisSize.min : MainAxisSize.max,
crossAxisAlignment:
fitContent ? CrossAxisAlignment.start : CrossAxisAlignment.stretch,
children: children,
);
}
}
/// A scrolling widget that parses and displays Markdown.
///
/// Supports all GitHub Flavored Markdown from the
/// [specification](https://github.github.com/gfm/).
///
/// See also:
///
/// * [MarkdownBody], which is a non-scrolling container of Markdown.
/// * <https://github.github.com/gfm/>
class Markdown extends MarkdownWidget {
/// Creates a scrolling widget that parses and displays Markdown.
const Markdown({
Key? key,
required String data,
bool selectable = false,
MarkdownStyleSheet? styleSheet,
MarkdownStyleSheetBaseTheme? styleSheetTheme,
SyntaxHighlighter? syntaxHighlighter,
MarkdownTapLinkCallback? onTapLink,
VoidCallback? onTapText,
String? imageDirectory,
List<md.BlockSyntax>? blockSyntaxes,
List<md.InlineSyntax>? inlineSyntaxes,
md.ExtensionSet? extensionSet,
MarkdownImageBuilder? imageBuilder,
MarkdownCheckboxBuilder? checkboxBuilder,
MarkdownBulletBuilder? bulletBuilder,
Map<String, MarkdownElementBuilder> builders =
const <String, MarkdownElementBuilder>{},
Map<String, MarkdownPaddingBuilder> paddingBuilders =
const <String, MarkdownPaddingBuilder>{},
MarkdownListItemCrossAxisAlignment listItemCrossAxisAlignment =
MarkdownListItemCrossAxisAlignment.baseline,
this.padding = const EdgeInsets.all(16.0),
this.controller,
this.physics,
this.shrinkWrap = false,
bool softLineBreak = false,
}) : super(
key: key,
data: data,
selectable: selectable,
styleSheet: styleSheet,
styleSheetTheme: styleSheetTheme,
syntaxHighlighter: syntaxHighlighter,
onTapLink: onTapLink,
onTapText: onTapText,
imageDirectory: imageDirectory,
blockSyntaxes: blockSyntaxes,
inlineSyntaxes: inlineSyntaxes,
extensionSet: extensionSet,
imageBuilder: imageBuilder,
checkboxBuilder: checkboxBuilder,
builders: builders,
paddingBuilders: paddingBuilders,
listItemCrossAxisAlignment: listItemCrossAxisAlignment,
bulletBuilder: bulletBuilder,
softLineBreak: softLineBreak,
);
/// The amount of space by which to inset the children.
final EdgeInsets padding;
/// An object that can be used to control the position to which this scroll view is scrolled.
///
/// See also: [ScrollView.controller]
final ScrollController? controller;
/// How the scroll view should respond to user input.
///
/// See also: [ScrollView.physics]
final ScrollPhysics? physics;
/// Whether the extent of the scroll view in the scroll direction should be
/// determined by the contents being viewed.
///
/// See also: [ScrollView.shrinkWrap]
final bool shrinkWrap;
@override
Widget build(BuildContext context, List<Widget>? children) {
return ListView(
padding: padding,
controller: controller,
physics: physics,
shrinkWrap: shrinkWrap,
children: children!,
);
}
}
/// Parse [task list items](https://github.github.com/gfm/#task-list-items-extension-).
class TaskListSyntax extends md.InlineSyntax {
/// Cretaes a new instance.
TaskListSyntax() : super(_pattern);
// FIXME: Waiting for dart-lang/markdown#269 to land
static const String _pattern = r'^ *\[([ xX])\] +';
@override
bool onMatch(md.InlineParser parser, Match match) {
final md.Element el = md.Element.withTag('input');
el.attributes['type'] = 'checkbox';
el.attributes['disabled'] = 'true';
el.attributes['checked'] = '${match[1]!.trim().isNotEmpty}';
parser.addNode(el);
return true;
}
}
/// An interface for an padding builder for element.
abstract class MarkdownPaddingBuilder {
/// Called when an Element has been reached, before its children have been
/// visited.
void visitElementBefore(md.Element element) {}
/// Called when a widget node has been rendering and need tag padding.
EdgeInsets getPadding() => EdgeInsets.zero;
}