diff --git a/packages/flutter_markdown/CHANGELOG.md b/packages/flutter_markdown/CHANGELOG.md index 7b19e96b64af..e0fe8c99c3f5 100644 --- a/packages/flutter_markdown/CHANGELOG.md +++ b/packages/flutter_markdown/CHANGELOG.md @@ -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. diff --git a/packages/flutter_markdown/lib/src/builder.dart b/packages/flutter_markdown/lib/src/builder.dart index 91371dd1c6e2..6188099429f0 100644 --- a/packages/flutter_markdown/lib/src/builder.dart +++ b/packages/flutter_markdown/lib/src/builder.dart @@ -707,52 +707,119 @@ class MarkdownBuilder implements md.NodeVisitor { } } + /// Extracts all spans from an inline element and merges them into a single list + Iterable _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 [span]; + } + + // Merge the style of the parent with the style of the children + final Iterable 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 _mergeInlineChildren( List children, TextAlign? textAlign, ) { + // List of merged text spans and widgets final List mergedTexts = []; + 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 children = previousTextSpan.children != null - ? previousTextSpan.children! - .map((InlineSpan span) => span is! TextSpan - ? TextSpan(children: [span]) - : span) - .toList() - : [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 children = previousTextSpan.children != null - ? List.from(previousTextSpan.children!) - : [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 spans = []; + + // 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([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([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; } @@ -827,19 +894,30 @@ class MarkdownBuilder implements md.NodeVisitor { } /// Combine text spans with equivalent properties into a single span. - TextSpan? _mergeSimilarTextSpans(List? textSpans) { - if (textSpans == null || textSpans.length < 2) { - return TextSpan(children: textSpans); + List _mergeSimilarTextSpans(List textSpans) { + if (textSpans.length < 2) { + return textSpans; } - final List mergedSpans = [textSpans.first]; + final List mergedSpans = []; 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([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, @@ -847,15 +925,13 @@ class MarkdownBuilder implements md.NodeVisitor { style: previous.style, )); } else { - mergedSpans.add(nextChild); + mergedSpans.addAll([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}) { diff --git a/packages/flutter_markdown/pubspec.yaml b/packages/flutter_markdown/pubspec.yaml index c5b8d4706f11..9811a80d213f 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.20+1 +version: 0.6.21 environment: sdk: ^3.3.0 diff --git a/packages/flutter_markdown/test/custom_syntax_test.dart b/packages/flutter_markdown/test/custom_syntax_test.dart index 1db01e3c0f9e..2bee4ebbf0bd 100644 --- a/packages/flutter_markdown/test/custom_syntax_test.dart +++ b/packages/flutter_markdown/test/custom_syntax_test.dart @@ -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()); }, ); @@ -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()); }, ); diff --git a/packages/flutter_markdown/test/inline_widget_test.dart b/packages/flutter_markdown/test/inline_widget_test.dart new file mode 100644 index 000000000000..d00102cc218a --- /dev/null +++ b/packages/flutter_markdown/test/inline_widget_test.dart @@ -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: { + 'sub': SubscriptBuilder(), + }, + extensionSet: md.ExtensionSet( + [], + [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()); + 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; + } +}