Skip to content

Commit dccde60

Browse files
authored
[flutter_markdown] Footnote support (flutter#5058)
1 parent d654f75 commit dccde60

File tree

7 files changed

+304
-9
lines changed

7 files changed

+304
-9
lines changed

packages/flutter_markdown/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
## 0.6.18
2+
3+
* Adds support for `footnote`.
4+
15
## 0.6.17+4
26

37
* Fixes an issue where a code block would overlap its container decoration.

packages/flutter_markdown/example/windows/runner/Runner.rc

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -60,14 +60,14 @@ IDI_APP_ICON ICON "resources\\app_icon.ico"
6060
// Version
6161
//
6262

63-
#ifdef FLUTTER_BUILD_NUMBER
64-
#define VERSION_AS_NUMBER FLUTTER_BUILD_NUMBER
63+
#if defined(FLUTTER_VERSION_MAJOR) && defined(FLUTTER_VERSION_MINOR) && defined(FLUTTER_VERSION_PATCH) && defined(FLUTTER_VERSION_BUILD)
64+
#define VERSION_AS_NUMBER FLUTTER_VERSION_MAJOR,FLUTTER_VERSION_MINOR,FLUTTER_VERSION_PATCH,FLUTTER_VERSION_BUILD
6565
#else
66-
#define VERSION_AS_NUMBER 1,0,0
66+
#define VERSION_AS_NUMBER 1,0,0,0
6767
#endif
6868

69-
#ifdef FLUTTER_BUILD_NAME
70-
#define VERSION_AS_STRING #FLUTTER_BUILD_NAME
69+
#if defined(FLUTTER_VERSION)
70+
#define VERSION_AS_STRING FLUTTER_VERSION
7171
#else
7272
#define VERSION_AS_STRING "1.0.0"
7373
#endif

packages/flutter_markdown/lib/src/builder.dart

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
// Use of this source code is governed by a BSD-style license that can be
33
// found in the LICENSE file.
44

5+
import 'dart:ui';
6+
57
import 'package:flutter/gestures.dart';
68
import 'package:flutter/material.dart';
79
import 'package:markdown/markdown.dart' as md;
@@ -27,7 +29,8 @@ const List<String> _kBlockTags = <String>[
2729
'table',
2830
'thead',
2931
'tbody',
30-
'tr'
32+
'tr',
33+
'section',
3134
];
3235

3336
const List<String> _kListTags = <String>['ul', 'ol'];
@@ -512,6 +515,29 @@ class MarkdownBuilder implements md.NodeVisitor {
512515
_ambiguate(_tables.single.rows.last.children)!.add(child);
513516
} else if (tag == 'a') {
514517
_linkHandlers.removeLast();
518+
} else if (tag == 'sup') {
519+
final Widget c = current.children.last;
520+
TextSpan? textSpan;
521+
if (c is RichText && c.text is TextSpan) {
522+
textSpan = c.text as TextSpan;
523+
} else if (c is SelectableText && c.textSpan is TextSpan) {
524+
textSpan = c.textSpan;
525+
}
526+
if (textSpan != null) {
527+
final Widget richText = _buildRichText(
528+
TextSpan(
529+
recognizer: textSpan.recognizer,
530+
text: element.textContent,
531+
style: textSpan.style?.copyWith(
532+
fontFeatures: <FontFeature>[
533+
const FontFeature.enable('sups'),
534+
],
535+
),
536+
),
537+
);
538+
current.children.removeLast();
539+
current.children.add(richText);
540+
}
515541
}
516542

517543
if (current.children.isNotEmpty) {

packages/flutter_markdown/pubspec.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ description: A Markdown renderer for Flutter. Create rich text output,
44
formatted with simple Markdown tags.
55
repository: https://github.com/flutter/packages/tree/main/packages/flutter_markdown
66
issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+flutter_markdown%22
7-
version: 0.6.17+4
7+
version: 0.6.18
88

99
environment:
1010
sdk: ">=3.0.0 <4.0.0"
@@ -13,7 +13,7 @@ environment:
1313
dependencies:
1414
flutter:
1515
sdk: flutter
16-
markdown: ^7.0.0
16+
markdown: ^7.1.1
1717
meta: ^1.3.0
1818
path: ^1.8.0
1919

packages/flutter_markdown/test/all.dart

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ library flutter_markdown.all_test;
77
import 'blockquote_test.dart' as blockquote_test;
88
import 'custom_syntax_test.dart' as custome_syntax_test;
99
import 'emphasis_test.dart' as emphasis_test;
10+
import 'footnote_test.dart' as footnote_test;
1011
import 'header_test.dart' as header_test;
1112
import 'horizontal_rule_test.dart' as horizontal_rule_test;
1213
import 'html_test.dart' as html_test;
@@ -26,6 +27,7 @@ void main() {
2627
blockquote_test.defineTests();
2728
custome_syntax_test.defineTests();
2829
emphasis_test.defineTests();
30+
footnote_test.defineTests();
2931
header_test.defineTests();
3032
horizontal_rule_test.defineTests();
3133
html_test.defineTests();
Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
// Copyright 2013 The Flutter Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style license that can be
3+
// found in the LICENSE file.
4+
5+
import 'package:flutter/gestures.dart';
6+
import 'package:flutter/widgets.dart';
7+
import 'package:flutter_markdown/flutter_markdown.dart';
8+
import 'package:flutter_test/flutter_test.dart';
9+
10+
import 'utils.dart';
11+
12+
void main() => defineTests();
13+
14+
void defineTests() {
15+
group(
16+
'structure',
17+
() {
18+
testWidgets(
19+
'footnote is detected and handle correctly',
20+
(WidgetTester tester) async {
21+
const String data = 'Foo[^a]\n[^a]: Bar';
22+
await tester.pumpWidget(
23+
boilerplate(
24+
const MarkdownBody(
25+
data: data,
26+
),
27+
),
28+
);
29+
30+
final Iterable<Widget> widgets = tester.allWidgets;
31+
expectTextStrings(widgets, <String>[
32+
'Foo1',
33+
'1.',
34+
'Bar ↩',
35+
]);
36+
},
37+
);
38+
39+
testWidgets(
40+
'footnote is detected and handle correctly for selectable markdown',
41+
(WidgetTester tester) async {
42+
const String data = 'Foo[^a]\n[^a]: Bar';
43+
await tester.pumpWidget(
44+
boilerplate(
45+
const MarkdownBody(
46+
data: data,
47+
selectable: true,
48+
),
49+
),
50+
);
51+
52+
final Iterable<Widget> widgets = tester.allWidgets;
53+
expectTextStrings(widgets, <String>[
54+
'Foo1',
55+
'1.',
56+
'Bar ↩',
57+
]);
58+
},
59+
);
60+
61+
testWidgets(
62+
'ignore footnotes without description',
63+
(WidgetTester tester) async {
64+
const String data = 'Foo[^1] Bar[^2]\n[^1]: Bar';
65+
await tester.pumpWidget(
66+
boilerplate(
67+
const MarkdownBody(
68+
data: data,
69+
),
70+
),
71+
);
72+
73+
final Iterable<Widget> widgets = tester.allWidgets;
74+
expectTextStrings(widgets, <String>[
75+
'Foo1 Bar[^2]',
76+
'1.',
77+
'Bar ↩',
78+
]);
79+
},
80+
);
81+
testWidgets(
82+
'ignore superscripts and footnotes order',
83+
(WidgetTester tester) async {
84+
const String data = '[^2]: Bar \n [^1]: Foo \n Foo[^f] Bar[^b]';
85+
await tester.pumpWidget(
86+
boilerplate(
87+
const MarkdownBody(
88+
data: data,
89+
),
90+
),
91+
);
92+
93+
final Iterable<Widget> widgets = tester.allWidgets;
94+
expectTextStrings(widgets, <String>[
95+
'Foo1 Bar2',
96+
'1.',
97+
'Foo ↩',
98+
'2.',
99+
'Bar ↩',
100+
]);
101+
},
102+
);
103+
104+
testWidgets(
105+
'handle two digits superscript',
106+
(WidgetTester tester) async {
107+
const String data = '''
108+
1[^1] 2[^2] 3[^3] 4[^4] 5[^5] 6[^6] 7[^7] 8[^8] 9[^9] 10[^10]
109+
[^1]:1
110+
[^2]:2
111+
[^3]:3
112+
[^4]:4
113+
[^5]:5
114+
[^6]:6
115+
[^7]:7
116+
[^8]:8
117+
[^9]:9
118+
[^10]:10
119+
''';
120+
await tester.pumpWidget(
121+
boilerplate(
122+
const MarkdownBody(
123+
data: data,
124+
),
125+
),
126+
);
127+
128+
final Iterable<Widget> widgets = tester.allWidgets;
129+
expectTextStrings(widgets, <String>[
130+
'11 22 33 44 55 66 77 88 99 1010',
131+
'1.',
132+
'1 ↩',
133+
'2.',
134+
'2 ↩',
135+
'3.',
136+
'3 ↩',
137+
'4.',
138+
'4 ↩',
139+
'5.',
140+
'5 ↩',
141+
'6.',
142+
'6 ↩',
143+
'7.',
144+
'7 ↩',
145+
'8.',
146+
'8 ↩',
147+
'9.',
148+
'9 ↩',
149+
'10.',
150+
'10 ↩',
151+
]);
152+
},
153+
);
154+
},
155+
);
156+
157+
group(
158+
'superscript textstyle replacing',
159+
() {
160+
testWidgets(
161+
'superscript has correct fontfeature',
162+
(WidgetTester tester) async {
163+
const String data = 'Foo[^a]\n[^a]: Bar';
164+
await tester.pumpWidget(
165+
boilerplate(
166+
const MarkdownBody(
167+
data: data,
168+
),
169+
),
170+
);
171+
172+
final Iterable<Widget> widgets = tester.allWidgets;
173+
final RichText richText = widgets
174+
.firstWhere((Widget widget) => widget is RichText) as RichText;
175+
176+
final TextSpan span = richText.text as TextSpan;
177+
final List<InlineSpan>? children = span.children;
178+
179+
expect(children, isNotNull);
180+
expect(children!.length, 2);
181+
expect(children[1].style, isNotNull);
182+
expect(children[1].style!.fontFeatures?.length, 1);
183+
expect(children[1].style!.fontFeatures?.first.feature, 'sups');
184+
},
185+
);
186+
187+
testWidgets(
188+
'superscript index has the same font style like text',
189+
(WidgetTester tester) async {
190+
const String data = '# Foo[^a]\n[^a]: Bar';
191+
await tester.pumpWidget(
192+
boilerplate(
193+
const MarkdownBody(
194+
data: data,
195+
),
196+
),
197+
);
198+
199+
final Iterable<Widget> widgets = tester.allWidgets;
200+
final RichText richText = widgets
201+
.firstWhere((Widget widget) => widget is RichText) as RichText;
202+
203+
final TextSpan span = richText.text as TextSpan;
204+
final List<InlineSpan>? children = span.children;
205+
206+
expect(children![0].style, isNotNull);
207+
expect(children[1].style!.fontSize, children[0].style!.fontSize);
208+
expect(children[1].style!.fontFamily, children[0].style!.fontFamily);
209+
expect(children[1].style!.fontStyle, children[0].style!.fontStyle);
210+
expect(children[1].style!.fontSize, children[0].style!.fontSize);
211+
},
212+
);
213+
214+
testWidgets(
215+
'link is correctly copied to new superscript index',
216+
(WidgetTester tester) async {
217+
final List<MarkdownLink> linkTapResults = <MarkdownLink>[];
218+
const String data = 'Foo[^a]\n[^a]: Bar';
219+
await tester.pumpWidget(
220+
boilerplate(
221+
MarkdownBody(
222+
data: data,
223+
onTapLink: (String text, String? href, String title) =>
224+
linkTapResults.add(MarkdownLink(text, href, title)),
225+
),
226+
),
227+
);
228+
229+
final Iterable<Widget> widgets = tester.allWidgets;
230+
final RichText richText = widgets
231+
.firstWhere((Widget widget) => widget is RichText) as RichText;
232+
233+
final TextSpan span = richText.text as TextSpan;
234+
235+
final List<Type> gestureRecognizerTypes = <Type>[];
236+
span.visitChildren((InlineSpan inlineSpan) {
237+
if (inlineSpan is TextSpan) {
238+
final TapGestureRecognizer? recognizer =
239+
inlineSpan.recognizer as TapGestureRecognizer?;
240+
gestureRecognizerTypes.add(recognizer?.runtimeType ?? Null);
241+
if (recognizer != null) {
242+
recognizer.onTap!();
243+
}
244+
}
245+
return true;
246+
});
247+
248+
expect(span.children!.length, 2);
249+
expect(
250+
gestureRecognizerTypes,
251+
orderedEquals(<Type>[Null, TapGestureRecognizer]),
252+
);
253+
expectLinkTap(linkTapResults[0], const MarkdownLink('1', '#fn-a'));
254+
},
255+
);
256+
},
257+
);
258+
}

packages/flutter_markdown/test/utils.dart

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,13 @@ void expectWidgetTypes(Iterable<Widget> widgets, List<Type> expected) {
3333
void expectTextStrings(Iterable<Widget> widgets, List<String> strings) {
3434
int currentString = 0;
3535
for (final Widget widget in widgets) {
36+
TextSpan? span;
3637
if (widget is RichText) {
37-
final TextSpan span = widget.text as TextSpan;
38+
span = widget.text as TextSpan;
39+
} else if (widget is SelectableText) {
40+
span = widget.textSpan;
41+
}
42+
if (span != null) {
3843
final String text = _extractTextFromTextSpan(span);
3944
expect(text, equals(strings[currentString]));
4045
currentString += 1;

0 commit comments

Comments
 (0)