445 lines
13 KiB
Dart
445 lines
13 KiB
Dart
// 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: <md.InlineSyntax>[SubscriptSyntax()],
|
|
builders: <String, MarkdownElementBuilder>{
|
|
'sub': SubscriptBuilder(),
|
|
},
|
|
),
|
|
),
|
|
);
|
|
|
|
final Iterable<Widget> widgets = tester.allWidgets;
|
|
expectTextStrings(widgets, <String>['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: <md.BlockSyntax>[NoteSyntax()],
|
|
builders: <String, MarkdownElementBuilder>{
|
|
'note': NoteBuilder(),
|
|
},
|
|
),
|
|
),
|
|
);
|
|
final ColoredBox container = tester.widgetList(find.byType(ColoredBox)).first as ColoredBox;
|
|
expect(container.color, Colors.red);
|
|
expect(container.child, isInstanceOf<Text>());
|
|
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: <md.BlockSyntax>[CustomTagBlockSyntax()],
|
|
builders: <String, MarkdownElementBuilder>{
|
|
'custom': CustomTagBlockBuilder(),
|
|
},
|
|
),
|
|
),
|
|
);
|
|
|
|
final ColoredBox container = tester.widgetList(find.byType(ColoredBox)).first as ColoredBox;
|
|
expect(container.color, Colors.red);
|
|
expect(container.child, isInstanceOf<Text>());
|
|
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: <md.InlineSyntax>[WikilinkSyntax()],
|
|
builders: <String, MarkdownElementBuilder>{
|
|
'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: <md.InlineSyntax>[ContainerSyntax()],
|
|
builders: <String, MarkdownElementBuilder>{
|
|
'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<Container>());
|
|
},
|
|
);
|
|
|
|
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: <md.InlineSyntax>[InlineTextColorSyntax()],
|
|
builders: <String, MarkdownElementBuilder>{
|
|
'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: <md.InlineSyntax>[ContainerSyntax()],
|
|
builders: <String, MarkdownElementBuilder>{
|
|
'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<Container>());
|
|
},
|
|
);
|
|
|
|
testWidgets(
|
|
'Custom rendering of tags without children',
|
|
(WidgetTester tester) async {
|
|
const String data = '';
|
|
await tester.pumpWidget(
|
|
boilerplate(
|
|
Markdown(
|
|
data: data,
|
|
builders: <String, MarkdownElementBuilder>{
|
|
'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<String> _subscripts = <String>['₀', '₁', '₂', '₃', '₄', '₅', '₆', '₇', '₈', '₉'];
|
|
|
|
@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>[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: <InlineSpan>[
|
|
WidgetSpan(
|
|
child: Container(),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class ContainerBuilder2 extends MarkdownElementBuilder {
|
|
@override
|
|
Widget? visitElementAfter(md.Element element, _) {
|
|
return Text.rich(
|
|
TextSpan(
|
|
children: <InlineSpan>[
|
|
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<String, Color> contentColors = <String, Color>{
|
|
'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.Node>[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;
|
|
}
|
|
}
|