// 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 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:markdown/markdown.dart' as md; import 'utils.dart'; void main() => defineTests(); void defineTests() { group('Custom Syntax', () { testWidgets( 'Subscript', (WidgetTester tester) async { await tester.pumpWidget( boilerplate( Markdown( data: 'H_2O', extensionSet: md.ExtensionSet.none, inlineSyntaxes: [SubscriptSyntax()], builders: { 'sub': SubscriptBuilder(), }, ), ), ); final Iterable widgets = tester.allWidgets; expectTextStrings(widgets, ['H₂O']); }, ); testWidgets( 'Custom block element', (WidgetTester tester) async { const String blockContent = 'note block'; await tester.pumpWidget( boilerplate( Markdown( data: '[!NOTE] $blockContent', extensionSet: md.ExtensionSet.none, blockSyntaxes: [NoteSyntax()], builders: { 'note': NoteBuilder(), }, ), ), ); final ColoredBox container = tester.widgetList(find.byType(ColoredBox)).first as ColoredBox; expect(container.color, Colors.red); expect(container.child, isInstanceOf()); expect((container.child! as Text).data, blockContent); }, ); testWidgets( 'Block with custom tag', (WidgetTester tester) async { const String textBefore = 'Before '; const String textAfter = ' After'; const String blockContent = 'Custom content rendered in a ColoredBox'; await tester.pumpWidget( boilerplate( Markdown( data: '$textBefore\n{{custom}}\n$blockContent\n{{/custom}}\n$textAfter', extensionSet: md.ExtensionSet.none, blockSyntaxes: [CustomTagBlockSyntax()], builders: { 'custom': CustomTagBlockBuilder(), }, ), ), ); final ColoredBox container = tester.widgetList(find.byType(ColoredBox)).first as ColoredBox; expect(container.color, Colors.red); expect(container.child, isInstanceOf()); expect((container.child! as Text).data, blockContent); }, ); testWidgets( 'link for wikistyle', (WidgetTester tester) async { await tester.pumpWidget( boilerplate( Markdown( data: 'This is a [[wiki link]]', extensionSet: md.ExtensionSet.none, inlineSyntaxes: [WikilinkSyntax()], builders: { 'wikilink': WikilinkBuilder(), }, ), ), ); final Text textWidget = tester.widget(find.byType(Text)); final TextSpan span = (textWidget.textSpan! as TextSpan).children![1] as TextSpan; expect(span.children, null); expect(span.recognizer.runtimeType, equals(TapGestureRecognizer)); }, ); testWidgets( 'WidgetSpan in Text.rich is handled correctly', (WidgetTester tester) async { await tester.pumpWidget( boilerplate( Markdown( data: 'container is a widget that allows to customize its child', extensionSet: md.ExtensionSet.none, inlineSyntaxes: [ContainerSyntax()], builders: { 'container': ContainerBuilder(), }, ), ), ); final Text textWidget = tester.widget(find.byType(Text)); final TextSpan textSpan = textWidget.textSpan! as TextSpan; final WidgetSpan widgetSpan = textSpan.children![0] as WidgetSpan; expect(widgetSpan.child, isInstanceOf()); }, ); testWidgets( 'visitElementAfterWithContext is handled correctly', (WidgetTester tester) async { await tester.pumpWidget( boilerplate( Markdown( data: r'# This is a header with some \color1{color} in it', extensionSet: md.ExtensionSet.none, inlineSyntaxes: [InlineTextColorSyntax()], builders: { 'inlineTextColor': InlineTextColorElementBuilder(), }, ), ), ); final Text textWidget = tester.widget(find.byType(Text)); final TextSpan rootSpan = textWidget.textSpan! as TextSpan; final TextSpan firstSpan = rootSpan.children![0] as TextSpan; final TextSpan secondSpan = rootSpan.children![1] as TextSpan; final TextSpan thirdSpan = rootSpan.children![2] as TextSpan; expect(secondSpan.style!.color, Colors.red); expect(secondSpan.style!.fontSize, firstSpan.style!.fontSize); expect(secondSpan.style!.fontSize, thirdSpan.style!.fontSize); }, ); }); testWidgets( 'TextSpan and WidgetSpan as children in Text.rich are handled correctly', (WidgetTester tester) async { await tester.pumpWidget( boilerplate( Markdown( data: 'this test replaces a string with a container', extensionSet: md.ExtensionSet.none, inlineSyntaxes: [ContainerSyntax()], builders: { 'container': ContainerBuilder2(), }, ), ), ); final Text textWidget = tester.widget(find.byType(Text)); final TextSpan textSpan = textWidget.textSpan! as TextSpan; final TextSpan start = textSpan.children![0] as TextSpan; expect(start.text, 'this test replaces a string with a '); final TextSpan foo = textSpan.children![1] as TextSpan; expect(foo.text, 'foo'); final WidgetSpan widgetSpan = textSpan.children![2] as WidgetSpan; expect(widgetSpan.child, isInstanceOf()); }, ); testWidgets( 'Custom rendering of tags without children', (WidgetTester tester) async { const String data = '![alt](/assets/images/logo.png)'; await tester.pumpWidget( boilerplate( Markdown( data: data, builders: { 'img': ImgBuilder(), }, ), ), ); final Finder imageFinder = find.byType(Image); expect(imageFinder, findsNothing); final Finder textFinder = find.byType(Text); expect(textFinder, findsOneWidget); final Text textWidget = tester.widget(find.byType(Text)); expect(textWidget.data, 'foo'); }, ); } class SubscriptSyntax extends md.InlineSyntax { SubscriptSyntax() : super(_pattern); static const String _pattern = r'_([0-9]+)'; @override bool onMatch(md.InlineParser parser, Match match) { parser.addNode(md.Element.text('sub', match[1]!)); return true; } } class SubscriptBuilder extends MarkdownElementBuilder { static const List _subscripts = ['₀', '₁', '₂', '₃', '₄', '₅', '₆', '₇', '₈', '₉']; @override Widget visitElementAfter(md.Element element, _) { // We don't currently have a way to control the vertical alignment of text spans. // See https://github.com/flutter/flutter/issues/10906#issuecomment-385723664 final String textContent = element.textContent; String text = ''; for (int i = 0; i < textContent.length; i++) { text += _subscripts[int.parse(textContent[i])]; } return Text.rich(TextSpan(text: text)); } } class WikilinkSyntax extends md.InlineSyntax { WikilinkSyntax() : super(_pattern); static const String _pattern = r'\[\[(.*?)\]\]'; @override bool onMatch(md.InlineParser parser, Match match) { final String link = match[1]!; final md.Element el = md.Element('wikilink', [md.Element.text('span', link)]) ..attributes['href'] = link.replaceAll(' ', '_'); parser.addNode(el); return true; } } class WikilinkBuilder extends MarkdownElementBuilder { @override Widget visitElementAfter(md.Element element, _) { final TapGestureRecognizer recognizer = TapGestureRecognizer()..onTap = () {}; addTearDown(recognizer.dispose); return Text.rich( TextSpan(text: element.textContent, recognizer: recognizer), ); } } class ContainerSyntax extends md.InlineSyntax { ContainerSyntax() : super(_pattern); static const String _pattern = 'container'; @override bool onMatch(md.InlineParser parser, Match match) { parser.addNode( md.Element.text('container', ''), ); return true; } } class ContainerBuilder extends MarkdownElementBuilder { @override Widget? visitElementAfter(md.Element element, _) { return Text.rich( TextSpan( children: [ WidgetSpan( child: Container(), ), ], ), ); } } class ContainerBuilder2 extends MarkdownElementBuilder { @override Widget? visitElementAfter(md.Element element, _) { return Text.rich( TextSpan( children: [ const TextSpan(text: 'foo'), WidgetSpan( child: Container(), ), ], ), ); } } // Note: The implementation of inline span is incomplete, it does not handle // bold, italic, ... text with a colored block. // This would not work: `\color1{Text with *bold* text}` class InlineTextColorSyntax extends md.InlineSyntax { InlineTextColorSyntax() : super(r'\\color([1-9]){(.*?)}'); @override bool onMatch(md.InlineParser parser, Match match) { final String colorId = match.group(1)!; final String textContent = match.group(2)!; final md.Element node = md.Element.text( 'inlineTextColor', textContent, )..attributes['color'] = colorId; parser.addNode(node); parser.addNode( md.Text(''), ); return true; } } class InlineTextColorElementBuilder extends MarkdownElementBuilder { @override Widget visitElementAfterWithContext( BuildContext context, md.Element element, TextStyle? preferredStyle, TextStyle? parentStyle, ) { final String innerText = element.textContent; final String color = element.attributes['color'] ?? ''; final Map contentColors = { '1': Colors.red, '2': Colors.green, '3': Colors.blue, }; final Color? contentColor = contentColors[color]; return Text.rich( TextSpan( text: innerText, style: parentStyle?.copyWith(color: contentColor), ), ); } } class ImgBuilder extends MarkdownElementBuilder { @override Widget? visitElementAfter(md.Element element, TextStyle? preferredStyle) { return Text('foo', style: preferredStyle); } } class NoteBuilder extends MarkdownElementBuilder { @override Widget? visitText(md.Text text, TextStyle? preferredStyle) { return ColoredBox(color: Colors.red, child: Text(text.text, style: preferredStyle)); } @override bool isBlockElement() { return true; } } class NoteSyntax extends md.BlockSyntax { @override md.Node? parse(md.BlockParser parser) { final md.Line line = parser.current; parser.advance(); return md.Element('note', [md.Text(line.content.substring(8))]); } @override RegExp get pattern => RegExp(r'^\[!NOTE] '); } class CustomTagBlockBuilder extends MarkdownElementBuilder { @override bool isBlockElement() => true; @override Widget visitElementAfterWithContext( BuildContext context, md.Element element, TextStyle? preferredStyle, TextStyle? parentStyle, ) { if (element.tag == 'custom') { final String content = element.attributes['content']!; return ColoredBox(color: Colors.red, child: Text(content, style: preferredStyle)); } return const SizedBox.shrink(); } } class CustomTagBlockSyntax extends md.BlockSyntax { @override bool canParse(md.BlockParser parser) { return parser.current.content.startsWith('{{custom}}'); } @override RegExp get pattern => RegExp(r'\{\{custom\}\}([\s\S]*?)\{\{/custom\}\}'); @override md.Node parse(md.BlockParser parser) { parser.advance(); final StringBuffer buffer = StringBuffer(); while (!parser.current.content.startsWith('{{/custom}}') && !parser.isDone) { buffer.writeln(parser.current.content); parser.advance(); } if (!parser.isDone) { parser.advance(); } final String content = buffer.toString().trim(); final md.Element element = md.Element.empty('custom'); element.attributes['content'] = content; return element; } }