Skip to content

Commit 5fed047

Browse files
authored
[flutter_markdown] Fix WidgetSpan Support in MarkdownElementBuilder (flutter#6225)
This pull request addresses a critical issue identified in the `flutter_markdown` package, specifically relating to the handling of `WidgetSpan` elements within `Text.rich` elements when utilized inside `MarkdownElementBuilder`s. Prior to this fix, the inclusion of `WidgetSpan` instances within markdown content led to casting issues, disrupting the normal rendering flow of the markdown content. Key Changes: - Resolved casting problems associated with using `WidgetSpan` inside `Text.rich` elements by refining the type handling and span merging logic within the affected functions. - Thoroughly commented and cleaned up the code within the error-prone functions to enhance readability and maintainability, ensuring that future modifications can be made more easily. - Adjusted existing tests to align with the newly introduced span merging logic, which is now more efficient and produces more predictable outcomes. - Introduced new tests specifically designed to cover scenarios where `WidgetSpan` elements are included within markdown content, ensuring that this issue does not resurface in future updates. The adjustments made in this pull request ensure that developers utilizing the `flutter_markdown` package can now seamlessly incorporate `WidgetSpan` elements within their markdown content without encountering the previously observed casting issues. This fix not only improves the package's robustness but also expands its flexibility, allowing for richer text compositions within markdown. This pull request closes issue [flutter#144383](flutter#144383), effectively addressing the reported bug and enhancing the overall functionality of the `flutter_markdown` package. I'm welcoming feedback and suggestions for improvement. Fixed issues: * [flutter#144383](flutter#144383)
1 parent 05f97df commit 5fed047

File tree

5 files changed

+210
-54
lines changed

5 files changed

+210
-54
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.21
2+
3+
* Fixes support for `WidgetSpan` in `Text.rich` elements inside `MarkdownElementBuilder`.
4+
15
## 0.6.20+1
26

37
* Updates minimum supported SDK version to Flutter 3.19.

packages/flutter_markdown/lib/src/builder.dart

Lines changed: 123 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -707,52 +707,119 @@ class MarkdownBuilder implements md.NodeVisitor {
707707
}
708708
}
709709

710+
/// Extracts all spans from an inline element and merges them into a single list
711+
Iterable<InlineSpan> _getInlineSpans(InlineSpan span) {
712+
// If the span is not a TextSpan or it has no children, return the span
713+
if (span is! TextSpan || span.children == null) {
714+
return <InlineSpan>[span];
715+
}
716+
717+
// Merge the style of the parent with the style of the children
718+
final Iterable<InlineSpan> spans =
719+
span.children!.map((InlineSpan childSpan) {
720+
if (childSpan is TextSpan) {
721+
return TextSpan(
722+
text: childSpan.text,
723+
recognizer: childSpan.recognizer,
724+
semanticsLabel: childSpan.semanticsLabel,
725+
style: childSpan.style?.merge(span.style),
726+
);
727+
} else {
728+
return childSpan;
729+
}
730+
});
731+
732+
return spans;
733+
}
734+
710735
/// Merges adjacent [TextSpan] children
711736
List<Widget> _mergeInlineChildren(
712737
List<Widget> children,
713738
TextAlign? textAlign,
714739
) {
740+
// List of merged text spans and widgets
715741
final List<Widget> mergedTexts = <Widget>[];
742+
716743
for (final Widget child in children) {
717-
if (mergedTexts.isNotEmpty && mergedTexts.last is Text && child is Text) {
718-
final Text previous = mergedTexts.removeLast() as Text;
719-
final TextSpan previousTextSpan = previous.textSpan! as TextSpan;
720-
final List<TextSpan> children = previousTextSpan.children != null
721-
? previousTextSpan.children!
722-
.map((InlineSpan span) => span is! TextSpan
723-
? TextSpan(children: <InlineSpan>[span])
724-
: span)
725-
.toList()
726-
: <TextSpan>[previousTextSpan];
727-
children.add(child.textSpan! as TextSpan);
728-
final TextSpan? mergedSpan = _mergeSimilarTextSpans(children);
729-
mergedTexts.add(_buildRichText(
730-
mergedSpan,
731-
textAlign: textAlign,
732-
));
733-
} else if (mergedTexts.isNotEmpty &&
734-
mergedTexts.last is SelectableText &&
735-
child is SelectableText) {
736-
final SelectableText previous =
737-
mergedTexts.removeLast() as SelectableText;
738-
final TextSpan previousTextSpan = previous.textSpan!;
739-
final List<TextSpan> children = previousTextSpan.children != null
740-
? List<TextSpan>.from(previousTextSpan.children!)
741-
: <TextSpan>[previousTextSpan];
742-
if (child.textSpan != null) {
743-
children.add(child.textSpan!);
744+
// If the list is empty, add the current widget to the list
745+
if (mergedTexts.isEmpty) {
746+
mergedTexts.add(child);
747+
continue;
748+
}
749+
750+
// Remove last widget from the list to merge it with the current widget
751+
final Widget last = mergedTexts.removeLast();
752+
753+
// Extracted spans from the last and the current widget
754+
List<InlineSpan> spans = <InlineSpan>[];
755+
756+
// Extract the text spans from the last widget
757+
if (last is SelectableText) {
758+
final TextSpan span = last.textSpan!;
759+
spans.addAll(_getInlineSpans(span));
760+
} else if (last is Text) {
761+
final InlineSpan span = last.textSpan!;
762+
spans.addAll(_getInlineSpans(span));
763+
} else if (last is RichText) {
764+
final InlineSpan span = last.text;
765+
spans.addAll(_getInlineSpans(span));
766+
} else {
767+
// If the last widget is not a text widget,
768+
// add both the last and the current widget to the list
769+
mergedTexts.addAll(<Widget>[last, child]);
770+
continue;
771+
}
772+
773+
// Extract the text spans from the current widget
774+
if (child is Text) {
775+
final InlineSpan span = child.textSpan!;
776+
spans.addAll(_getInlineSpans(span));
777+
} else if (child is SelectableText) {
778+
final TextSpan span = child.textSpan!;
779+
spans.addAll(_getInlineSpans(span));
780+
} else if (child is RichText) {
781+
final InlineSpan span = child.text;
782+
spans.addAll(_getInlineSpans(span));
783+
} else {
784+
// If the current widget is not a text widget,
785+
// add both the last and the current widget to the list
786+
mergedTexts.addAll(<Widget>[last, child]);
787+
continue;
788+
}
789+
790+
if (spans.isNotEmpty) {
791+
// Merge similar text spans
792+
spans = _mergeSimilarTextSpans(spans);
793+
794+
// Create a new text widget with the merged text spans
795+
InlineSpan child;
796+
if (spans.length == 1) {
797+
child = spans.first;
798+
} else {
799+
child = TextSpan(children: spans);
800+
}
801+
802+
// Add the new text widget to the list
803+
if (selectable) {
804+
mergedTexts.add(SelectableText.rich(
805+
TextSpan(children: spans),
806+
textScaler: styleSheet.textScaler,
807+
textAlign: textAlign ?? TextAlign.start,
808+
onTap: onTapText,
809+
));
810+
} else {
811+
mergedTexts.add(Text.rich(
812+
child,
813+
textScaler: styleSheet.textScaler,
814+
textAlign: textAlign ?? TextAlign.start,
815+
));
744816
}
745-
final TextSpan? mergedSpan = _mergeSimilarTextSpans(children);
746-
mergedTexts.add(
747-
_buildRichText(
748-
mergedSpan,
749-
textAlign: textAlign,
750-
),
751-
);
752817
} else {
818+
// If no text spans were found, add the current widget to the list
753819
mergedTexts.add(child);
754820
}
755821
}
822+
756823
return mergedTexts;
757824
}
758825

@@ -827,35 +894,44 @@ class MarkdownBuilder implements md.NodeVisitor {
827894
}
828895

829896
/// Combine text spans with equivalent properties into a single span.
830-
TextSpan? _mergeSimilarTextSpans(List<TextSpan>? textSpans) {
831-
if (textSpans == null || textSpans.length < 2) {
832-
return TextSpan(children: textSpans);
897+
List<InlineSpan> _mergeSimilarTextSpans(List<InlineSpan> textSpans) {
898+
if (textSpans.length < 2) {
899+
return textSpans;
833900
}
834901

835-
final List<TextSpan> mergedSpans = <TextSpan>[textSpans.first];
902+
final List<InlineSpan> mergedSpans = <InlineSpan>[];
836903

837904
for (int index = 1; index < textSpans.length; index++) {
838-
final TextSpan nextChild = textSpans[index];
839-
if (nextChild.recognizer == mergedSpans.last.recognizer &&
840-
nextChild.semanticsLabel == mergedSpans.last.semanticsLabel &&
841-
nextChild.style == mergedSpans.last.style) {
842-
final TextSpan previous = mergedSpans.removeLast();
905+
final InlineSpan previous =
906+
mergedSpans.isEmpty ? textSpans.first : mergedSpans.removeLast();
907+
final InlineSpan nextChild = textSpans[index];
908+
909+
final bool previousIsTextSpan = previous is TextSpan;
910+
final bool nextIsTextSpan = nextChild is TextSpan;
911+
if (!previousIsTextSpan || !nextIsTextSpan) {
912+
mergedSpans.addAll(<InlineSpan>[previous, nextChild]);
913+
continue;
914+
}
915+
916+
final bool matchStyle = nextChild.recognizer == previous.recognizer &&
917+
nextChild.semanticsLabel == previous.semanticsLabel &&
918+
nextChild.style == previous.style;
919+
920+
if (matchStyle) {
843921
mergedSpans.add(TextSpan(
844922
text: previous.toPlainText() + nextChild.toPlainText(),
845923
recognizer: previous.recognizer,
846924
semanticsLabel: previous.semanticsLabel,
847925
style: previous.style,
848926
));
849927
} else {
850-
mergedSpans.add(nextChild);
928+
mergedSpans.addAll(<InlineSpan>[previous, nextChild]);
851929
}
852930
}
853931

854932
// When the mergered spans compress into a single TextSpan return just that
855933
// TextSpan, otherwise bundle the set of TextSpans under a single parent.
856-
return mergedSpans.length == 1
857-
? mergedSpans.first
858-
: TextSpan(children: mergedSpans);
934+
return mergedSpans;
859935
}
860936

861937
Widget _buildRichText(TextSpan? text, {TextAlign? textAlign, String? key}) {

packages/flutter_markdown/pubspec.yaml

Lines changed: 1 addition & 1 deletion
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.20+1
7+
version: 0.6.21
88

99
environment:
1010
sdk: ^3.3.0

packages/flutter_markdown/test/custom_syntax_test.dart

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -77,9 +77,8 @@ void defineTests() {
7777
);
7878

7979
final Text textWidget = tester.widget(find.byType(Text));
80-
final TextSpan span =
81-
(textWidget.textSpan! as TextSpan).children![0] as TextSpan;
82-
final WidgetSpan widgetSpan = span.children![0] as WidgetSpan;
80+
final TextSpan textSpan = textWidget.textSpan! as TextSpan;
81+
final WidgetSpan widgetSpan = textSpan.children![0] as WidgetSpan;
8382
expect(widgetSpan.child, isInstanceOf<Container>());
8483
},
8584
);
@@ -133,10 +132,9 @@ void defineTests() {
133132
final TextSpan textSpan = textWidget.textSpan! as TextSpan;
134133
final TextSpan start = textSpan.children![0] as TextSpan;
135134
expect(start.text, 'this test replaces a string with a ');
136-
final TextSpan end = textSpan.children![1] as TextSpan;
137-
final TextSpan foo = end.children![0] as TextSpan;
135+
final TextSpan foo = textSpan.children![1] as TextSpan;
138136
expect(foo.text, 'foo');
139-
final WidgetSpan widgetSpan = end.children![1] as WidgetSpan;
137+
final WidgetSpan widgetSpan = textSpan.children![2] as WidgetSpan;
140138
expect(widgetSpan.child, isInstanceOf<Container>());
141139
},
142140
);
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
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/cupertino.dart';
6+
import 'package:flutter/material.dart';
7+
import 'package:flutter_markdown/flutter_markdown.dart';
8+
import 'package:flutter_test/flutter_test.dart';
9+
import 'package:markdown/markdown.dart' as md;
10+
11+
import 'utils.dart';
12+
13+
void main() => defineTests();
14+
15+
void defineTests() {
16+
group('InlineWidget', () {
17+
testWidgets(
18+
'Test inline widget',
19+
(WidgetTester tester) async {
20+
await tester.pumpWidget(
21+
boilerplate(
22+
MarkdownBody(
23+
data: 'Hello, foo bar',
24+
builders: <String, MarkdownElementBuilder>{
25+
'sub': SubscriptBuilder(),
26+
},
27+
extensionSet: md.ExtensionSet(
28+
<md.BlockSyntax>[],
29+
<md.InlineSyntax>[SubscriptSyntax()],
30+
),
31+
),
32+
),
33+
);
34+
35+
final Text textWidget = tester.firstWidget(find.byType(Text));
36+
final TextSpan span = textWidget.textSpan! as TextSpan;
37+
38+
final TextSpan part1 = span.children![0] as TextSpan;
39+
expect(part1.toPlainText(), 'Hello, ');
40+
41+
final WidgetSpan part2 = span.children![1] as WidgetSpan;
42+
expect(part2.alignment, PlaceholderAlignment.middle);
43+
expect(part2.child, isA<Text>());
44+
expect((part2.child as Text).data, 'foo');
45+
46+
final TextSpan part3 = span.children![2] as TextSpan;
47+
expect(part3.toPlainText(), ' bar');
48+
},
49+
);
50+
});
51+
}
52+
53+
class SubscriptBuilder extends MarkdownElementBuilder {
54+
@override
55+
Widget visitElementAfterWithContext(
56+
BuildContext context,
57+
md.Element element,
58+
TextStyle? preferredStyle,
59+
TextStyle? parentStyle,
60+
) {
61+
return Text.rich(WidgetSpan(
62+
alignment: PlaceholderAlignment.middle,
63+
child: Text(element.textContent),
64+
));
65+
}
66+
}
67+
68+
class SubscriptSyntax extends md.InlineSyntax {
69+
SubscriptSyntax() : super(_pattern);
70+
71+
static const String _pattern = r'(foo)';
72+
73+
@override
74+
bool onMatch(md.InlineParser parser, Match match) {
75+
parser.addNode(md.Element.text('sub', match[1]!));
76+
return true;
77+
}
78+
}

0 commit comments

Comments
 (0)