From dccde603981c92a62fecef8d4fca61d27113ebcc Mon Sep 17 00:00:00 2001 From: Dawid Wenderski Date: Wed, 4 Oct 2023 20:31:18 +0200 Subject: [PATCH] [flutter_markdown] Footnote support (#5058) --- packages/flutter_markdown/CHANGELOG.md | 4 + .../example/windows/runner/Runner.rc | 10 +- .../flutter_markdown/lib/src/builder.dart | 28 +- packages/flutter_markdown/pubspec.yaml | 4 +- packages/flutter_markdown/test/all.dart | 2 + .../flutter_markdown/test/footnote_test.dart | 258 ++++++++++++++++++ packages/flutter_markdown/test/utils.dart | 7 +- 7 files changed, 304 insertions(+), 9 deletions(-) create mode 100644 packages/flutter_markdown/test/footnote_test.dart diff --git a/packages/flutter_markdown/CHANGELOG.md b/packages/flutter_markdown/CHANGELOG.md index 004b4ce991af..49e8034a9ee1 100644 --- a/packages/flutter_markdown/CHANGELOG.md +++ b/packages/flutter_markdown/CHANGELOG.md @@ -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. diff --git a/packages/flutter_markdown/example/windows/runner/Runner.rc b/packages/flutter_markdown/example/windows/runner/Runner.rc index 7b61799edc3f..4cd6ba9bf83a 100644 --- a/packages/flutter_markdown/example/windows/runner/Runner.rc +++ b/packages/flutter_markdown/example/windows/runner/Runner.rc @@ -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 diff --git a/packages/flutter_markdown/lib/src/builder.dart b/packages/flutter_markdown/lib/src/builder.dart index 846c7cda5096..07cde26ad8b9 100644 --- a/packages/flutter_markdown/lib/src/builder.dart +++ b/packages/flutter_markdown/lib/src/builder.dart @@ -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; @@ -27,7 +29,8 @@ const List _kBlockTags = [ 'table', 'thead', 'tbody', - 'tr' + 'tr', + 'section', ]; const List _kListTags = ['ul', 'ol']; @@ -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: [ + const FontFeature.enable('sups'), + ], + ), + ), + ); + current.children.removeLast(); + current.children.add(richText); + } } if (current.children.isNotEmpty) { diff --git a/packages/flutter_markdown/pubspec.yaml b/packages/flutter_markdown/pubspec.yaml index 37a2d48eb97f..3a6eddec1b7a 100644 --- a/packages/flutter_markdown/pubspec.yaml +++ b/packages/flutter_markdown/pubspec.yaml @@ -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" @@ -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 diff --git a/packages/flutter_markdown/test/all.dart b/packages/flutter_markdown/test/all.dart index b348f8e23ec1..c580ad7dfd5a 100644 --- a/packages/flutter_markdown/test/all.dart +++ b/packages/flutter_markdown/test/all.dart @@ -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; @@ -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(); diff --git a/packages/flutter_markdown/test/footnote_test.dart b/packages/flutter_markdown/test/footnote_test.dart new file mode 100644 index 000000000000..b02b3af05256 --- /dev/null +++ b/packages/flutter_markdown/test/footnote_test.dart @@ -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 widgets = tester.allWidgets; + expectTextStrings(widgets, [ + '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 widgets = tester.allWidgets; + expectTextStrings(widgets, [ + '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 widgets = tester.allWidgets; + expectTextStrings(widgets, [ + '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 widgets = tester.allWidgets; + expectTextStrings(widgets, [ + '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 widgets = tester.allWidgets; + expectTextStrings(widgets, [ + '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 widgets = tester.allWidgets; + final RichText richText = widgets + .firstWhere((Widget widget) => widget is RichText) as RichText; + + final TextSpan span = richText.text as TextSpan; + final List? 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 widgets = tester.allWidgets; + final RichText richText = widgets + .firstWhere((Widget widget) => widget is RichText) as RichText; + + final TextSpan span = richText.text as TextSpan; + final List? 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 linkTapResults = []; + 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 widgets = tester.allWidgets; + final RichText richText = widgets + .firstWhere((Widget widget) => widget is RichText) as RichText; + + final TextSpan span = richText.text as TextSpan; + + final List gestureRecognizerTypes = []; + 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([Null, TapGestureRecognizer]), + ); + expectLinkTap(linkTapResults[0], const MarkdownLink('1', '#fn-a')); + }, + ); + }, + ); +} diff --git a/packages/flutter_markdown/test/utils.dart b/packages/flutter_markdown/test/utils.dart index 544b8b27a2e4..3c9991099512 100644 --- a/packages/flutter_markdown/test/utils.dart +++ b/packages/flutter_markdown/test/utils.dart @@ -33,8 +33,13 @@ void expectWidgetTypes(Iterable widgets, List expected) { void expectTextStrings(Iterable widgets, List 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;