diff --git a/packages/flutter_markdown/CHANGELOG.md b/packages/flutter_markdown/CHANGELOG.md index 8291d31eae..17ca4301e1 100644 --- a/packages/flutter_markdown/CHANGELOG.md +++ b/packages/flutter_markdown/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.6.1 + + * Added builder option bulletBuilder + ## 0.6.0 * Null safety release diff --git a/packages/flutter_markdown/lib/src/builder.dart b/packages/flutter_markdown/lib/src/builder.dart index 7c21107277..9083ec2565 100644 --- a/packages/flutter_markdown/lib/src/builder.dart +++ b/packages/flutter_markdown/lib/src/builder.dart @@ -10,25 +10,7 @@ import '_functions_io.dart' if (dart.library.html) '_functions_web.dart'; import 'style_sheet.dart'; import 'widget.dart'; -const List _kBlockTags = const [ - 'p', - 'h1', - 'h2', - 'h3', - 'h4', - 'h5', - 'h6', - 'li', - 'blockquote', - 'pre', - 'ol', - 'ul', - 'hr', - 'table', - 'thead', - 'tbody', - 'tr' -]; +const List _kBlockTags = const ['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'li', 'blockquote', 'pre', 'ol', 'ul', 'hr', 'table', 'thead', 'tbody', 'tr']; const List _kListTags = const ['ul', 'ol']; @@ -99,6 +81,7 @@ class MarkdownBuilder implements md.NodeVisitor { required this.imageDirectory, required this.imageBuilder, required this.checkboxBuilder, + required this.bulletBuilder, required this.builders, required this.listItemCrossAxisAlignment, this.fitContent = false, @@ -125,6 +108,9 @@ class MarkdownBuilder implements md.NodeVisitor { /// Call when build a checkbox widget. final MarkdownCheckboxBuilder? checkboxBuilder; + /// Called when building a custom bullet. + final MarkdownBulletBuilder? bulletBuilder; + /// Call when build a custom widget. final Map builders; @@ -188,16 +174,14 @@ class MarkdownBuilder implements md.NodeVisitor { _addAnonymousBlockIfNeeded(); if (_isListTag(tag)) { _listIndents.add(tag); - if (element.attributes["start"] != null) - start = int.parse(element.attributes["start"]!) - 1; + if (element.attributes["start"] != null) start = int.parse(element.attributes["start"]!) - 1; } else if (tag == 'blockquote') { _isInBlockquote = true; } else if (tag == 'table') { _tables.add(_TableElement()); } else if (tag == 'tr') { final length = _tables.single.rows.length; - BoxDecoration? decoration = - styleSheet.tableCellsDecoration as BoxDecoration?; + BoxDecoration? decoration = styleSheet.tableCellsDecoration as BoxDecoration?; if (length == 0 || length % 2 == 1) decoration = null; _tables.single.rows.add(TableRow( decoration: decoration, @@ -227,9 +211,7 @@ class MarkdownBuilder implements md.NodeVisitor { // The Markdown parser passes empty table data tags for blank // table cells. Insert a text node with an empty string in this // case for the table cell to get properly created. - if (element.tag == 'td' && - element.children != null && - element.children!.isEmpty) { + if (element.tag == 'td' && element.children != null && element.children!.isEmpty) { element.children!.add(md.Text('')); } @@ -244,13 +226,7 @@ class MarkdownBuilder implements md.NodeVisitor { } String? extractTextFromElement(element) { - return element is md.Element && (element.children?.isNotEmpty ?? false) - ? element.children! - .map((e) => e is md.Text ? e.text : extractTextFromElement(e)) - .join("") - : ((element.attributes?.isNotEmpty ?? false) - ? element.attributes["alt"] - : ""); + return element is md.Element && (element.children?.isNotEmpty ?? false) ? element.children!.map((e) => e is md.Text ? e.text : extractTextFromElement(e)).join("") : ((element.attributes?.isNotEmpty ?? false) ? element.attributes["alt"] : ""); } @override @@ -286,8 +262,7 @@ class MarkdownBuilder implements md.NodeVisitor { Widget? child; if (_blocks.isNotEmpty && builders.containsKey(_blocks.last.tag)) { - child = builders[_blocks.last.tag!]! - .visitText(text, styleSheet.styles[_blocks.last.tag!]); + child = builders[_blocks.last.tag!]!.visitText(text, styleSheet.styles[_blocks.last.tag!]); } else if (_blocks.last.tag == 'pre') { child = Scrollbar( child: SingleChildScrollView( @@ -299,9 +274,7 @@ class MarkdownBuilder implements md.NodeVisitor { } else { child = _buildRichText( TextSpan( - style: _isInBlockquote - ? styleSheet.blockquote!.merge(_inlines.last.style) - : _inlines.last.style, + style: _isInBlockquote ? styleSheet.blockquote!.merge(_inlines.last.style) : _inlines.last.style, text: _isInBlockquote ? text.text : trimText(text.text), recognizer: _linkHandlers.isNotEmpty ? _linkHandlers.last : null, ), @@ -325,9 +298,7 @@ class MarkdownBuilder implements md.NodeVisitor { if (current.children.isNotEmpty) { child = Column( - crossAxisAlignment: fitContent - ? CrossAxisAlignment.start - : CrossAxisAlignment.stretch, + crossAxisAlignment: fitContent ? CrossAxisAlignment.start : CrossAxisAlignment.stretch, children: current.children, ); } else { @@ -351,19 +322,11 @@ class MarkdownBuilder implements md.NodeVisitor { bullet = _buildBullet(_listIndents.last); } child = Row( - textBaseline: listItemCrossAxisAlignment == - MarkdownListItemCrossAxisAlignment.start - ? null - : TextBaseline.alphabetic, - crossAxisAlignment: listItemCrossAxisAlignment == - MarkdownListItemCrossAxisAlignment.start - ? CrossAxisAlignment.start - : CrossAxisAlignment.baseline, + textBaseline: listItemCrossAxisAlignment == MarkdownListItemCrossAxisAlignment.start ? null : TextBaseline.alphabetic, + crossAxisAlignment: listItemCrossAxisAlignment == MarkdownListItemCrossAxisAlignment.start ? CrossAxisAlignment.start : CrossAxisAlignment.baseline, children: [ SizedBox( - width: styleSheet.listIndent! + - styleSheet.listBulletPadding!.left + - styleSheet.listBulletPadding!.right, + width: styleSheet.listIndent! + styleSheet.listBulletPadding!.left + styleSheet.listBulletPadding!.right, child: bullet, ), Expanded(child: child) @@ -401,8 +364,7 @@ class MarkdownBuilder implements md.NodeVisitor { final _InlineElement parent = _inlines.last; if (builders.containsKey(tag)) { - final Widget? child = - builders[tag]!.visitElementAfter(element, styleSheet.styles[tag]); + final Widget? child = builders[tag]!.visitElementAfter(element, styleSheet.styles[tag]); if (child != null) current.children[0] = child; } else if (tag == 'img') { // create an image widget for this image @@ -474,8 +436,7 @@ class MarkdownBuilder implements md.NodeVisitor { } if (_linkHandlers.isNotEmpty) { - TapGestureRecognizer recognizer = - _linkHandlers.last as TapGestureRecognizer; + TapGestureRecognizer recognizer = _linkHandlers.last as TapGestureRecognizer; return GestureDetector(child: child, onTap: recognizer.onTap); } else { return child; @@ -497,7 +458,17 @@ class MarkdownBuilder implements md.NodeVisitor { } Widget _buildBullet(String listTag) { - if (listTag == 'ul') { + final int index = _blocks.last.nextListIndex; + final bool isUnordered = listTag == 'ul'; + + if (bulletBuilder != null) { + return Padding( + padding: styleSheet.listBulletPadding!, + child: bulletBuilder!(index, isUnordered ? BulletStyle.unorderedList : BulletStyle.orderedList), + ); + } + + if (isUnordered) { return Padding( padding: styleSheet.listBulletPadding!, child: Text( @@ -508,7 +479,6 @@ class MarkdownBuilder implements md.NodeVisitor { ); } - final int index = _blocks.last.nextListIndex; return Padding( padding: styleSheet.listBulletPadding!, child: Text( @@ -583,28 +553,20 @@ class MarkdownBuilder implements md.NodeVisitor { ) { List mergedTexts = []; for (Widget child in children) { - if (mergedTexts.isNotEmpty && - mergedTexts.last is RichText && - child is RichText) { + if (mergedTexts.isNotEmpty && mergedTexts.last is RichText && child is RichText) { RichText previous = mergedTexts.removeLast() as RichText; TextSpan previousTextSpan = previous.text as TextSpan; - List children = previousTextSpan.children != null - ? List.from(previousTextSpan.children!) - : [previousTextSpan]; + List children = previousTextSpan.children != null ? List.from(previousTextSpan.children!) : [previousTextSpan]; children.add(child.text as TextSpan); TextSpan? mergedSpan = _mergeSimilarTextSpans(children); mergedTexts.add(_buildRichText( mergedSpan, textAlign: textAlign, )); - } else if (mergedTexts.isNotEmpty && - mergedTexts.last is SelectableText && - child is SelectableText) { + } else if (mergedTexts.isNotEmpty && mergedTexts.last is SelectableText && child is SelectableText) { SelectableText previous = mergedTexts.removeLast() as SelectableText; TextSpan previousTextSpan = previous.textSpan!; - List children = previousTextSpan.children != null - ? List.from(previousTextSpan.children!) - : [previousTextSpan]; + List children = previousTextSpan.children != null ? List.from(previousTextSpan.children!) : [previousTextSpan]; if (child.textSpan != null) { children.add(child.textSpan!); } @@ -667,10 +629,7 @@ class MarkdownBuilder implements md.NodeVisitor { for (int index = 1; index < textSpans.length; index++) { TextSpan? nextChild = textSpans[index]; - if (nextChild is TextSpan && - nextChild.recognizer == mergedSpans.last.recognizer && - nextChild.semanticsLabel == mergedSpans.last.semanticsLabel && - nextChild.style == mergedSpans.last.style) { + if (nextChild is TextSpan && nextChild.recognizer == mergedSpans.last.recognizer && nextChild.semanticsLabel == mergedSpans.last.semanticsLabel && nextChild.style == mergedSpans.last.style) { TextSpan previous = mergedSpans.removeLast(); mergedSpans.add(TextSpan( text: previous.toPlainText() + nextChild.toPlainText(), @@ -685,9 +644,7 @@ class MarkdownBuilder implements md.NodeVisitor { // When the mergered spans compress into a single TextSpan return just that // TextSpan, otherwise bundle the set of TextSpans under a single parent. - return mergedSpans.length == 1 - ? mergedSpans.first - : TextSpan(children: mergedSpans); + return mergedSpans.length == 1 ? mergedSpans.first : TextSpan(children: mergedSpans); } Widget _buildRichText(TextSpan? text, {TextAlign? textAlign}) { diff --git a/packages/flutter_markdown/lib/src/widget.dart b/packages/flutter_markdown/lib/src/widget.dart index 60535357ce..060861dae1 100644 --- a/packages/flutter_markdown/lib/src/widget.dart +++ b/packages/flutter_markdown/lib/src/widget.dart @@ -32,6 +32,19 @@ typedef Widget MarkdownImageBuilder(Uri uri, String? title, String? alt); /// Used by [MarkdownWidget.checkboxBuilder] typedef Widget MarkdownCheckboxBuilder(bool value); +/// Signature for custom bullet widget. +/// +/// Used by [MarkdownWidget.bulletBuilder] +typedef Widget MarkdownBulletBuilder(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 { + orderedList, + unorderedList, +} + /// Creates a format [TextSpan] given a string. /// /// Used by [MarkdownWidget] to highlight the contents of `pre` elements. @@ -61,8 +74,7 @@ abstract class MarkdownElementBuilder { /// to [preferredStyle]. /// /// If you needn't build a widget, return null. - Widget? visitElementAfter(md.Element element, TextStyle? preferredStyle) => - null; + Widget? visitElementAfter(md.Element element, TextStyle? preferredStyle) => null; } /// Enum to specify which theme being used when creating [MarkdownStyleSheet] @@ -136,10 +148,10 @@ abstract class MarkdownWidget extends StatefulWidget { this.extensionSet, this.imageBuilder, this.checkboxBuilder, + this.bulletBuilder, this.builders = const {}, this.fitContent = false, - this.listItemCrossAxisAlignment = - MarkdownListItemCrossAxisAlignment.baseline, + this.listItemCrossAxisAlignment = MarkdownListItemCrossAxisAlignment.baseline, }) : super(key: key); /// The Markdown to display. @@ -191,6 +203,9 @@ abstract class MarkdownWidget extends StatefulWidget { /// 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: @@ -223,8 +238,7 @@ abstract class MarkdownWidget extends StatefulWidget { _MarkdownWidgetState createState() => _MarkdownWidgetState(); } -class _MarkdownWidgetState extends State - implements MarkdownBuilderDelegate { +class _MarkdownWidgetState extends State implements MarkdownBuilderDelegate { List? _children; final List _recognizers = []; @@ -237,8 +251,7 @@ class _MarkdownWidgetState extends State @override void didUpdateWidget(MarkdownWidget oldWidget) { super.didUpdateWidget(oldWidget); - if (widget.data != oldWidget.data || - widget.styleSheet != oldWidget.styleSheet) { + if (widget.data != oldWidget.data || widget.styleSheet != oldWidget.styleSheet) { _parseMarkdown(); } } @@ -250,10 +263,8 @@ class _MarkdownWidgetState extends State } void _parseMarkdown() { - final MarkdownStyleSheet fallbackStyleSheet = - kFallbackStyle(context, widget.styleSheetTheme); - final MarkdownStyleSheet styleSheet = - fallbackStyleSheet.merge(widget.styleSheet); + final MarkdownStyleSheet fallbackStyleSheet = kFallbackStyle(context, widget.styleSheetTheme); + final MarkdownStyleSheet styleSheet = fallbackStyleSheet.merge(widget.styleSheet); _disposeRecognizers(); @@ -277,6 +288,7 @@ class _MarkdownWidgetState extends State imageDirectory: widget.imageDirectory, imageBuilder: widget.imageBuilder, checkboxBuilder: widget.checkboxBuilder, + bulletBuilder: widget.bulletBuilder, builders: widget.builders, fitContent: widget.fitContent, listItemCrossAxisAlignment: widget.listItemCrossAxisAlignment, @@ -288,8 +300,7 @@ class _MarkdownWidgetState extends State void _disposeRecognizers() { if (_recognizers.isEmpty) return; - final List localRecognizers = - List.from(_recognizers); + final List localRecognizers = List.from(_recognizers); _recognizers.clear(); for (GestureRecognizer recognizer in localRecognizers) recognizer.dispose(); } @@ -345,9 +356,9 @@ class MarkdownBody extends MarkdownWidget { md.ExtensionSet? extensionSet, MarkdownImageBuilder? imageBuilder, MarkdownCheckboxBuilder? checkboxBuilder, + MarkdownBulletBuilder? bulletBuilder, Map builders = const {}, - MarkdownListItemCrossAxisAlignment listItemCrossAxisAlignment = - MarkdownListItemCrossAxisAlignment.baseline, + MarkdownListItemCrossAxisAlignment listItemCrossAxisAlignment = MarkdownListItemCrossAxisAlignment.baseline, this.shrinkWrap = true, this.fitContent = true, }) : super( @@ -367,6 +378,7 @@ class MarkdownBody extends MarkdownWidget { checkboxBuilder: checkboxBuilder, builders: builders, listItemCrossAxisAlignment: listItemCrossAxisAlignment, + bulletBuilder: bulletBuilder, ); /// See [ScrollView.shrinkWrap] @@ -380,8 +392,7 @@ class MarkdownBody extends MarkdownWidget { if (children!.length == 1) return children.single; return Column( mainAxisSize: shrinkWrap ? MainAxisSize.min : MainAxisSize.max, - crossAxisAlignment: - fitContent ? CrossAxisAlignment.start : CrossAxisAlignment.stretch, + crossAxisAlignment: fitContent ? CrossAxisAlignment.start : CrossAxisAlignment.stretch, children: children, ); } @@ -413,9 +424,9 @@ class Markdown extends MarkdownWidget { md.ExtensionSet? extensionSet, MarkdownImageBuilder? imageBuilder, MarkdownCheckboxBuilder? checkboxBuilder, + MarkdownBulletBuilder? bulletBuilder, Map builders = const {}, - MarkdownListItemCrossAxisAlignment listItemCrossAxisAlignment = - MarkdownListItemCrossAxisAlignment.baseline, + MarkdownListItemCrossAxisAlignment listItemCrossAxisAlignment = MarkdownListItemCrossAxisAlignment.baseline, this.padding = const EdgeInsets.all(16.0), this.controller, this.physics, @@ -437,6 +448,7 @@ class Markdown extends MarkdownWidget { checkboxBuilder: checkboxBuilder, builders: builders, listItemCrossAxisAlignment: listItemCrossAxisAlignment, + bulletBuilder: bulletBuilder, ); /// The amount of space by which to inset the children. diff --git a/packages/flutter_markdown/test/list_test.dart b/packages/flutter_markdown/test/list_test.dart index f93f381aef..f64bad9763 100644 --- a/packages/flutter_markdown/test/list_test.dart +++ b/packages/flutter_markdown/test/list_test.dart @@ -69,18 +69,7 @@ void defineTests() { ); final Iterable widgets = tester.allWidgets; - expectTextStrings(widgets, [ - '1.', - 'Item 1', - '2.', - 'Item 2', - '3.', - 'Item 3', - '10.', - 'Item 10', - '11.', - 'Item 11' - ]); + expectTextStrings(widgets, ['1.', 'Item 1', '2.', 'Item 2', '3.', 'Item 3', '10.', 'Item 10', '11.', 'Item 11']); }, ); }); @@ -107,12 +96,35 @@ void defineTests() { }, ); + testWidgets('custom bullet builder', (WidgetTester tester) async { + final String data = '* Item 1\n* Item 2\n1) Item 3\n2) Item 4'; + final MarkdownBulletBuilder builder = (int index, BulletStyle style) => Text('$index ${style == BulletStyle.orderedList ? 'ordered' : 'unordered'}'); + + await tester.pumpWidget( + boilerplate( + Markdown(data: data, bulletBuilder: builder), + ), + ); + + final Iterable widgets = tester.allWidgets; + + expectTextStrings(widgets, [ + '0 unordered', + 'Item 1', + '1 unordered', + 'Item 2', + '0 ordered', + 'Item 3', + '1 ordered', + 'Item 4', + ]); + }); + testWidgets( 'custom checkbox builder', (WidgetTester tester) async { const String data = '- [x] Item 1\n- [ ] Item 2'; - final MarkdownCheckboxBuilder builder = - (bool checked) => Text('$checked'); + final MarkdownCheckboxBuilder builder = (bool checked) => Text('$checked'); await tester.pumpWidget( boilerplate(