Skip to content

[flutter_markdown] Footnote support #5058

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Oct 4, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/flutter_markdown/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
## 0.6.18

* Adds support for `footnote`.

## 0.6.17+4

* Fixes an issue where a code block would overlap its container decoration.
Expand Down
10 changes: 5 additions & 5 deletions packages/flutter_markdown/example/windows/runner/Runner.rc
Original file line number Diff line number Diff line change
Expand Up @@ -60,14 +60,14 @@ IDI_APP_ICON ICON "resources\\app_icon.ico"
// Version
//

#ifdef FLUTTER_BUILD_NUMBER
#define VERSION_AS_NUMBER FLUTTER_BUILD_NUMBER
#if defined(FLUTTER_VERSION_MAJOR) && defined(FLUTTER_VERSION_MINOR) && defined(FLUTTER_VERSION_PATCH) && defined(FLUTTER_VERSION_BUILD)
#define VERSION_AS_NUMBER FLUTTER_VERSION_MAJOR,FLUTTER_VERSION_MINOR,FLUTTER_VERSION_PATCH,FLUTTER_VERSION_BUILD
#else
#define VERSION_AS_NUMBER 1,0,0
#define VERSION_AS_NUMBER 1,0,0,0
#endif

#ifdef FLUTTER_BUILD_NAME
#define VERSION_AS_STRING #FLUTTER_BUILD_NAME
#if defined(FLUTTER_VERSION)
#define VERSION_AS_STRING FLUTTER_VERSION
#else
#define VERSION_AS_STRING "1.0.0"
#endif
Expand Down
28 changes: 27 additions & 1 deletion packages/flutter_markdown/lib/src/builder.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import 'dart:ui';

import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:markdown/markdown.dart' as md;
Expand All @@ -27,7 +29,8 @@ const List<String> _kBlockTags = <String>[
'table',
'thead',
'tbody',
'tr'
'tr',
'section',
];

const List<String> _kListTags = <String>['ul', 'ol'];
Expand Down Expand Up @@ -512,6 +515,29 @@ class MarkdownBuilder implements md.NodeVisitor {
_ambiguate(_tables.single.rows.last.children)!.add(child);
} else if (tag == 'a') {
_linkHandlers.removeLast();
} else if (tag == 'sup') {
final Widget c = current.children.last;
TextSpan? textSpan;
if (c is RichText && c.text is TextSpan) {
textSpan = c.text as TextSpan;
} else if (c is SelectableText && c.textSpan is TextSpan) {
textSpan = c.textSpan;
}
if (textSpan != null) {
final Widget richText = _buildRichText(
TextSpan(
recognizer: textSpan.recognizer,
text: element.textContent,
style: textSpan.style?.copyWith(
fontFeatures: <FontFeature>[
const FontFeature.enable('sups'),
],
),
),
);
current.children.removeLast();
current.children.add(richText);
}
}

if (current.children.isNotEmpty) {
Expand Down
4 changes: 2 additions & 2 deletions packages/flutter_markdown/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ description: A Markdown renderer for Flutter. Create rich text output,
formatted with simple Markdown tags.
repository: https://github.com/flutter/packages/tree/main/packages/flutter_markdown
issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+flutter_markdown%22
version: 0.6.17+4
version: 0.6.18

environment:
sdk: ">=3.0.0 <4.0.0"
Expand All @@ -13,7 +13,7 @@ environment:
dependencies:
flutter:
sdk: flutter
markdown: ^7.0.0
markdown: ^7.1.1
meta: ^1.3.0
path: ^1.8.0

Expand Down
2 changes: 2 additions & 0 deletions packages/flutter_markdown/test/all.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ library flutter_markdown.all_test;
import 'blockquote_test.dart' as blockquote_test;
import 'custom_syntax_test.dart' as custome_syntax_test;
import 'emphasis_test.dart' as emphasis_test;
import 'footnote_test.dart' as footnote_test;
import 'header_test.dart' as header_test;
import 'horizontal_rule_test.dart' as horizontal_rule_test;
import 'html_test.dart' as html_test;
Expand All @@ -26,6 +27,7 @@ void main() {
blockquote_test.defineTests();
custome_syntax_test.defineTests();
emphasis_test.defineTests();
footnote_test.defineTests();
header_test.defineTests();
horizontal_rule_test.defineTests();
html_test.defineTests();
Expand Down
258 changes: 258 additions & 0 deletions packages/flutter_markdown/test/footnote_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,258 @@
// 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/widgets.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
import 'package:flutter_test/flutter_test.dart';

import 'utils.dart';

void main() => defineTests();

void defineTests() {
group(
'structure',
() {
testWidgets(
'footnote is detected and handle correctly',
(WidgetTester tester) async {
const String data = 'Foo[^a]\n[^a]: Bar';
await tester.pumpWidget(
boilerplate(
const MarkdownBody(
data: data,
),
),
);

final Iterable<Widget> widgets = tester.allWidgets;
expectTextStrings(widgets, <String>[
'Foo1',
'1.',
'Bar ↩',
]);
},
);

testWidgets(
'footnote is detected and handle correctly for selectable markdown',
(WidgetTester tester) async {
const String data = 'Foo[^a]\n[^a]: Bar';
await tester.pumpWidget(
boilerplate(
const MarkdownBody(
data: data,
selectable: true,
),
),
);

final Iterable<Widget> widgets = tester.allWidgets;
expectTextStrings(widgets, <String>[
'Foo1',
'1.',
'Bar ↩',
]);
},
);

testWidgets(
'ignore footnotes without description',
(WidgetTester tester) async {
const String data = 'Foo[^1] Bar[^2]\n[^1]: Bar';
await tester.pumpWidget(
boilerplate(
const MarkdownBody(
data: data,
),
),
);

final Iterable<Widget> widgets = tester.allWidgets;
expectTextStrings(widgets, <String>[
'Foo1 Bar[^2]',
'1.',
'Bar ↩',
]);
},
);
testWidgets(
'ignore superscripts and footnotes order',
(WidgetTester tester) async {
const String data = '[^2]: Bar \n [^1]: Foo \n Foo[^f] Bar[^b]';
await tester.pumpWidget(
boilerplate(
const MarkdownBody(
data: data,
),
),
);

final Iterable<Widget> widgets = tester.allWidgets;
expectTextStrings(widgets, <String>[
'Foo1 Bar2',
'1.',
'Foo ↩',
'2.',
'Bar ↩',
]);
},
);

testWidgets(
'handle two digits superscript',
(WidgetTester tester) async {
const String data = '''
1[^1] 2[^2] 3[^3] 4[^4] 5[^5] 6[^6] 7[^7] 8[^8] 9[^9] 10[^10]
[^1]:1
[^2]:2
[^3]:3
[^4]:4
[^5]:5
[^6]:6
[^7]:7
[^8]:8
[^9]:9
[^10]:10
''';
await tester.pumpWidget(
boilerplate(
const MarkdownBody(
data: data,
),
),
);

final Iterable<Widget> widgets = tester.allWidgets;
expectTextStrings(widgets, <String>[
'11 22 33 44 55 66 77 88 99 1010',
'1.',
'1 ↩',
'2.',
'2 ↩',
'3.',
'3 ↩',
'4.',
'4 ↩',
'5.',
'5 ↩',
'6.',
'6 ↩',
'7.',
'7 ↩',
'8.',
'8 ↩',
'9.',
'9 ↩',
'10.',
'10 ↩',
]);
},
);
},
);

group(
'superscript textstyle replacing',
() {
testWidgets(
'superscript has correct fontfeature',
(WidgetTester tester) async {
const String data = 'Foo[^a]\n[^a]: Bar';
await tester.pumpWidget(
boilerplate(
const MarkdownBody(
data: data,
),
),
);

final Iterable<Widget> widgets = tester.allWidgets;
final RichText richText = widgets
.firstWhere((Widget widget) => widget is RichText) as RichText;

final TextSpan span = richText.text as TextSpan;
final List<InlineSpan>? children = span.children;

expect(children, isNotNull);
expect(children!.length, 2);
expect(children[1].style, isNotNull);
expect(children[1].style!.fontFeatures?.length, 1);
expect(children[1].style!.fontFeatures?.first.feature, 'sups');
},
);

testWidgets(
'superscript index has the same font style like text',
(WidgetTester tester) async {
const String data = '# Foo[^a]\n[^a]: Bar';
await tester.pumpWidget(
boilerplate(
const MarkdownBody(
data: data,
),
),
);

final Iterable<Widget> widgets = tester.allWidgets;
final RichText richText = widgets
.firstWhere((Widget widget) => widget is RichText) as RichText;

final TextSpan span = richText.text as TextSpan;
final List<InlineSpan>? children = span.children;

expect(children![0].style, isNotNull);
expect(children[1].style!.fontSize, children[0].style!.fontSize);
expect(children[1].style!.fontFamily, children[0].style!.fontFamily);
expect(children[1].style!.fontStyle, children[0].style!.fontStyle);
expect(children[1].style!.fontSize, children[0].style!.fontSize);
},
);

testWidgets(
'link is correctly copied to new superscript index',
(WidgetTester tester) async {
final List<MarkdownLink> linkTapResults = <MarkdownLink>[];
const String data = 'Foo[^a]\n[^a]: Bar';
await tester.pumpWidget(
boilerplate(
MarkdownBody(
data: data,
onTapLink: (String text, String? href, String title) =>
linkTapResults.add(MarkdownLink(text, href, title)),
),
),
);

final Iterable<Widget> widgets = tester.allWidgets;
final RichText richText = widgets
.firstWhere((Widget widget) => widget is RichText) as RichText;

final TextSpan span = richText.text as TextSpan;

final List<Type> gestureRecognizerTypes = <Type>[];
span.visitChildren((InlineSpan inlineSpan) {
if (inlineSpan is TextSpan) {
final TapGestureRecognizer? recognizer =
inlineSpan.recognizer as TapGestureRecognizer?;
gestureRecognizerTypes.add(recognizer?.runtimeType ?? Null);
if (recognizer != null) {
recognizer.onTap!();
}
}
return true;
});

expect(span.children!.length, 2);
expect(
gestureRecognizerTypes,
orderedEquals(<Type>[Null, TapGestureRecognizer]),
);
expectLinkTap(linkTapResults[0], const MarkdownLink('1', '#fn-a'));
},
);
},
);
}
7 changes: 6 additions & 1 deletion packages/flutter_markdown/test/utils.dart
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,13 @@ void expectWidgetTypes(Iterable<Widget> widgets, List<Type> expected) {
void expectTextStrings(Iterable<Widget> widgets, List<String> strings) {
int currentString = 0;
for (final Widget widget in widgets) {
TextSpan? span;
if (widget is RichText) {
final TextSpan span = widget.text as TextSpan;
span = widget.text as TextSpan;
} else if (widget is SelectableText) {
span = widget.textSpan;
}
if (span != null) {
final String text = _extractTextFromTextSpan(span);
expect(text, equals(strings[currentString]));
currentString += 1;
Expand Down