Skip to content

[flutter_markdown] Fix WidgetSpan Support in MarkdownElementBuilder #6225

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 24 commits into from
Mar 5, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
d466fab
Enable RichText merging
pzierahn Feb 27, 2024
413e890
Merge remote-tracking branch 'origin/main'
pzierahn Feb 27, 2024
3ad85bf
Make text selectable
pzierahn Feb 27, 2024
9ed8601
Added _getInlineSpans function
pzierahn Feb 28, 2024
6c9fe97
Fix 'WidgetSpan in Text.rich is handled correctly' test
pzierahn Feb 28, 2024
6db3f83
Modify _mergeSimilarTextSpans to handle a list of InlineSpans
pzierahn Feb 28, 2024
716f1e7
Fix test 'TextSpan and WidgetSpan as children in Text.rich are handle…
pzierahn Feb 28, 2024
82f6e63
Fix issue where certain children were not added correctly
pzierahn Feb 28, 2024
ff30c87
Update comments
pzierahn Feb 28, 2024
4771d84
Added inline widget tests
pzierahn Feb 29, 2024
60e981b
Update CHANGELOG.md
pzierahn Feb 29, 2024
b1e8ecb
Merge branch 'main' into main
pzierahn Feb 29, 2024
5179ca6
Merge branch 'main' into main
domesticmouse Mar 1, 2024
0aef52c
Fix missing type annotation
pzierahn Mar 2, 2024
8e438bf
Remove print
pzierahn Mar 2, 2024
e3ea4fd
Merge remote-tracking branch 'origin/main'
pzierahn Mar 2, 2024
1ae7cd0
Merge branch 'main' into main
pzierahn Mar 2, 2024
8525899
Update package version
pzierahn Mar 2, 2024
69876de
Merge remote-tracking branch 'origin/main'
pzierahn Mar 2, 2024
10c0c7a
Merge branch 'main' into main
domesticmouse Mar 4, 2024
6dfd49d
Ensure that sub spans match parent style
pzierahn Mar 4, 2024
ffae188
Simplified _getInlineSpans function
pzierahn Mar 4, 2024
7b35dc8
Merge branch 'main' into main
domesticmouse Mar 4, 2024
041bc04
Remove comment
pzierahn Mar 5, 2024
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.21

* Fixes support for `WidgetSpan` in `Text.rich` elements inside `MarkdownElementBuilder`.

## 0.6.20+1

* Updates minimum supported SDK version to Flutter 3.19.
Expand Down
170 changes: 123 additions & 47 deletions packages/flutter_markdown/lib/src/builder.dart
Original file line number Diff line number Diff line change
Expand Up @@ -707,52 +707,119 @@ class MarkdownBuilder implements md.NodeVisitor {
}
}

/// Extracts all spans from an inline element and merges them into a single list
Iterable<InlineSpan> _getInlineSpans(InlineSpan span) {
// If the span is not a TextSpan or it has no children, return the span
if (span is! TextSpan || span.children == null) {
return <InlineSpan>[span];
}

// Merge the style of the parent with the style of the children
final Iterable<InlineSpan> spans =
span.children!.map((InlineSpan childSpan) {
if (childSpan is TextSpan) {
return TextSpan(
text: childSpan.text,
recognizer: childSpan.recognizer,
semanticsLabel: childSpan.semanticsLabel,
style: childSpan.style?.merge(span.style),
);
} else {
return childSpan;
}
});

return spans;
}

/// Merges adjacent [TextSpan] children
List<Widget> _mergeInlineChildren(
List<Widget> children,
TextAlign? textAlign,
) {
// List of merged text spans and widgets
final List<Widget> mergedTexts = <Widget>[];

for (final Widget child in children) {
if (mergedTexts.isNotEmpty && mergedTexts.last is Text && child is Text) {
final Text previous = mergedTexts.removeLast() as Text;
final TextSpan previousTextSpan = previous.textSpan! as TextSpan;
final List<TextSpan> children = previousTextSpan.children != null
? previousTextSpan.children!
.map((InlineSpan span) => span is! TextSpan
? TextSpan(children: <InlineSpan>[span])
: span)
.toList()
: <TextSpan>[previousTextSpan];
children.add(child.textSpan! as TextSpan);
final TextSpan? mergedSpan = _mergeSimilarTextSpans(children);
mergedTexts.add(_buildRichText(
mergedSpan,
textAlign: textAlign,
));
} else if (mergedTexts.isNotEmpty &&
mergedTexts.last is SelectableText &&
child is SelectableText) {
final SelectableText previous =
mergedTexts.removeLast() as SelectableText;
final TextSpan previousTextSpan = previous.textSpan!;
final List<TextSpan> children = previousTextSpan.children != null
? List<TextSpan>.from(previousTextSpan.children!)
: <TextSpan>[previousTextSpan];
if (child.textSpan != null) {
children.add(child.textSpan!);
// If the list is empty, add the current widget to the list
if (mergedTexts.isEmpty) {
mergedTexts.add(child);
continue;
}

// Remove last widget from the list to merge it with the current widget
final Widget last = mergedTexts.removeLast();

// Extracted spans from the last and the current widget
List<InlineSpan> spans = <InlineSpan>[];

// Extract the text spans from the last widget
if (last is SelectableText) {
final TextSpan span = last.textSpan!;
spans.addAll(_getInlineSpans(span));
} else if (last is Text) {
final InlineSpan span = last.textSpan!;
spans.addAll(_getInlineSpans(span));
} else if (last is RichText) {
final InlineSpan span = last.text;
spans.addAll(_getInlineSpans(span));
} else {
// If the last widget is not a text widget,
// add both the last and the current widget to the list
mergedTexts.addAll(<Widget>[last, child]);
continue;
}

// Extract the text spans from the current widget
if (child is Text) {
final InlineSpan span = child.textSpan!;
spans.addAll(_getInlineSpans(span));
} else if (child is SelectableText) {
final TextSpan span = child.textSpan!;
spans.addAll(_getInlineSpans(span));
} else if (child is RichText) {
final InlineSpan span = child.text;
spans.addAll(_getInlineSpans(span));
} else {
// If the current widget is not a text widget,
// add both the last and the current widget to the list
mergedTexts.addAll(<Widget>[last, child]);
continue;
}

if (spans.isNotEmpty) {
// Merge similar text spans
spans = _mergeSimilarTextSpans(spans);

// Create a new text widget with the merged text spans
InlineSpan child;
if (spans.length == 1) {
child = spans.first;
} else {
child = TextSpan(children: spans);
}

// Add the new text widget to the list
if (selectable) {
mergedTexts.add(SelectableText.rich(
TextSpan(children: spans),
textScaler: styleSheet.textScaler,
textAlign: textAlign ?? TextAlign.start,
onTap: onTapText,
));
} else {
mergedTexts.add(Text.rich(
child,
textScaler: styleSheet.textScaler,
textAlign: textAlign ?? TextAlign.start,
));
}
final TextSpan? mergedSpan = _mergeSimilarTextSpans(children);
mergedTexts.add(
_buildRichText(
mergedSpan,
textAlign: textAlign,
),
);
} else {
// If no text spans were found, add the current widget to the list
mergedTexts.add(child);
}
}

return mergedTexts;
}

Expand Down Expand Up @@ -827,35 +894,44 @@ class MarkdownBuilder implements md.NodeVisitor {
}

/// Combine text spans with equivalent properties into a single span.
TextSpan? _mergeSimilarTextSpans(List<TextSpan>? textSpans) {
if (textSpans == null || textSpans.length < 2) {
return TextSpan(children: textSpans);
List<InlineSpan> _mergeSimilarTextSpans(List<InlineSpan> textSpans) {
if (textSpans.length < 2) {
return textSpans;
}

final List<TextSpan> mergedSpans = <TextSpan>[textSpans.first];
final List<InlineSpan> mergedSpans = <InlineSpan>[];

for (int index = 1; index < textSpans.length; index++) {
final TextSpan nextChild = textSpans[index];
if (nextChild.recognizer == mergedSpans.last.recognizer &&
nextChild.semanticsLabel == mergedSpans.last.semanticsLabel &&
nextChild.style == mergedSpans.last.style) {
final TextSpan previous = mergedSpans.removeLast();
final InlineSpan previous =
mergedSpans.isEmpty ? textSpans.first : mergedSpans.removeLast();
final InlineSpan nextChild = textSpans[index];

final bool previousIsTextSpan = previous is TextSpan;
final bool nextIsTextSpan = nextChild is TextSpan;
if (!previousIsTextSpan || !nextIsTextSpan) {
mergedSpans.addAll(<InlineSpan>[previous, nextChild]);
continue;
}

final bool matchStyle = nextChild.recognizer == previous.recognizer &&
nextChild.semanticsLabel == previous.semanticsLabel &&
nextChild.style == previous.style;

if (matchStyle) {
mergedSpans.add(TextSpan(
text: previous.toPlainText() + nextChild.toPlainText(),
recognizer: previous.recognizer,
semanticsLabel: previous.semanticsLabel,
style: previous.style,
));
} else {
mergedSpans.add(nextChild);
mergedSpans.addAll(<InlineSpan>[previous, nextChild]);
}
}

// When the mergered spans compress into a single TextSpan return just that
// TextSpan, otherwise bundle the set of TextSpans under a single parent.
return mergedSpans.length == 1
? mergedSpans.first
: TextSpan(children: mergedSpans);
return mergedSpans;
}

Widget _buildRichText(TextSpan? text, {TextAlign? textAlign, String? key}) {
Expand Down
2 changes: 1 addition & 1 deletion 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.20+1
version: 0.6.21

environment:
sdk: ^3.3.0
Expand Down
10 changes: 4 additions & 6 deletions packages/flutter_markdown/test/custom_syntax_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -77,9 +77,8 @@ void defineTests() {
);

final Text textWidget = tester.widget(find.byType(Text));
final TextSpan span =
(textWidget.textSpan! as TextSpan).children![0] as TextSpan;
final WidgetSpan widgetSpan = span.children![0] as WidgetSpan;
final TextSpan textSpan = textWidget.textSpan! as TextSpan;
final WidgetSpan widgetSpan = textSpan.children![0] as WidgetSpan;
expect(widgetSpan.child, isInstanceOf<Container>());
},
);
Expand Down Expand Up @@ -133,10 +132,9 @@ void defineTests() {
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 end = textSpan.children![1] as TextSpan;
final TextSpan foo = end.children![0] as TextSpan;
final TextSpan foo = textSpan.children![1] as TextSpan;
expect(foo.text, 'foo');
final WidgetSpan widgetSpan = end.children![1] as WidgetSpan;
final WidgetSpan widgetSpan = textSpan.children![2] as WidgetSpan;
expect(widgetSpan.child, isInstanceOf<Container>());
},
);
Expand Down
78 changes: 78 additions & 0 deletions packages/flutter_markdown/test/inline_widget_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
// 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/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:markdown/markdown.dart' as md;

import 'utils.dart';

void main() => defineTests();

void defineTests() {
group('InlineWidget', () {
testWidgets(
'Test inline widget',
(WidgetTester tester) async {
await tester.pumpWidget(
boilerplate(
MarkdownBody(
data: 'Hello, foo bar',
builders: <String, MarkdownElementBuilder>{
'sub': SubscriptBuilder(),
},
extensionSet: md.ExtensionSet(
<md.BlockSyntax>[],
<md.InlineSyntax>[SubscriptSyntax()],
),
),
),
);

final Text textWidget = tester.firstWidget(find.byType(Text));
final TextSpan span = textWidget.textSpan! as TextSpan;

final TextSpan part1 = span.children![0] as TextSpan;
expect(part1.toPlainText(), 'Hello, ');

final WidgetSpan part2 = span.children![1] as WidgetSpan;
expect(part2.alignment, PlaceholderAlignment.middle);
expect(part2.child, isA<Text>());
expect((part2.child as Text).data, 'foo');

final TextSpan part3 = span.children![2] as TextSpan;
expect(part3.toPlainText(), ' bar');
},
);
});
}

class SubscriptBuilder extends MarkdownElementBuilder {
@override
Widget visitElementAfterWithContext(
BuildContext context,
md.Element element,
TextStyle? preferredStyle,
TextStyle? parentStyle,
) {
return Text.rich(WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: Text(element.textContent),
));
}
}

class SubscriptSyntax extends md.InlineSyntax {
SubscriptSyntax() : super(_pattern);

static const String _pattern = r'(foo)';

@override
bool onMatch(md.InlineParser parser, Match match) {
parser.addNode(md.Element.text('sub', match[1]!));
return true;
}
}