// 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/material.dart'; import 'package:markdown/markdown.dart' as md; import '_functions_io.dart' if (dart.library.js_interop) '_functions_web.dart'; import 'style_sheet.dart'; import 'widget.dart'; final List _kBlockTags = [ 'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'li', 'blockquote', 'pre', 'ol', 'ul', 'hr', 'table', 'thead', 'tbody', 'tr', 'section', ]; const List _kListTags = ['ul', 'ol']; bool _isBlockTag(String? tag) => _kBlockTags.contains(tag); bool _isListTag(String tag) => _kListTags.contains(tag); class _BlockElement { _BlockElement(this.tag); final String? tag; final List children = []; int nextListIndex = 0; } class _TableElement { final List rows = []; } /// A collection of widgets that should be placed adjacent to (inline with) /// other inline elements in the same parent block. /// /// Inline elements can be textual (a/em/strong) represented by [Text.rich] /// widgets or images (img) represented by [Image.network] widgets. /// /// Inline elements can be nested within other inline elements, inheriting their /// parent's style along with the style of the block they are in. /// /// When laying out inline widgets, first, any adjacent Text.rich widgets are /// merged, then, all inline widgets are enclosed in a parent [Wrap] widget. class _InlineElement { _InlineElement(this.tag, {this.style}); final String? tag; /// Created by merging the style defined for this element's [tag] in the /// delegate's [MarkdownStyleSheet] with the style of its parent. final TextStyle? style; final List children = []; } /// A delegate used by [MarkdownBuilder] to control the widgets it creates. abstract class MarkdownBuilderDelegate { /// Returns the [BuildContext] of the [MarkdownWidget]. /// /// The context will be passed down to the /// [MarkdownElementBuilder.visitElementBefore] method and allows elements to /// get information from the context. BuildContext get context; /// Returns a gesture recognizer to use for an `a` element with the given /// text, `href` attribute, and title. GestureRecognizer createLink(String text, String? href, String title); /// Returns formatted text to use to display the given contents of a `pre` /// element. /// /// The `styleSheet` is the value of [MarkdownBuilder.styleSheet]. TextSpan formatText(MarkdownStyleSheet styleSheet, String code); } /// Builds a [Widget] tree from parsed Markdown. /// /// See also: /// /// * [Markdown], which is a widget that parses and displays Markdown. class MarkdownBuilder implements md.NodeVisitor { /// Creates an object that builds a [Widget] tree from parsed Markdown. MarkdownBuilder({ required this.delegate, required this.selectable, required this.styleSheet, required this.imageDirectory, required this.imageBuilder, required this.checkboxBuilder, required this.bulletBuilder, required this.builders, required this.paddingBuilders, required this.listItemCrossAxisAlignment, this.fitContent = false, this.onSelectionChanged, this.onTapText, this.softLineBreak = false, }); /// A delegate that controls how link and `pre` elements behave. final MarkdownBuilderDelegate delegate; /// If true, the text is selectable. /// /// Defaults to false. final bool selectable; /// Defines which [TextStyle] objects to use for each type of element. final MarkdownStyleSheet styleSheet; /// The base directory holding images referenced by Img tags with local or network file paths. final String? imageDirectory; /// Call when build an image widget. final MarkdownImageBuilder? imageBuilder; /// Call when build a checkbox widget. final MarkdownCheckboxBuilder? checkboxBuilder; /// Called when building a custom bullet. final MarkdownBulletBuilder? bulletBuilder; /// Call when build a custom widget. final Map builders; /// Call when build a padding for widget. final Map paddingBuilders; /// Whether to allow the widget to fit the child content. final bool fitContent; /// Controls the cross axis alignment for the bullet and list item content /// in lists. /// /// Defaults to [MarkdownListItemCrossAxisAlignment.baseline], which /// does not allow for intrinsic height measurements. final MarkdownListItemCrossAxisAlignment listItemCrossAxisAlignment; /// Called when the user changes selection when [selectable] is set to true. final MarkdownOnSelectionChangedCallback? onSelectionChanged; /// Default tap handler used when [selectable] is set to true final VoidCallback? onTapText; /// The soft line break is used to identify the spaces at the end of aline of /// text and the leading spaces in the immediately following the line of text. /// /// Default these spaces are removed in accordance with the Markdown /// specification on soft line breaks when lines of text are joined. final bool softLineBreak; final List _listIndents = []; final List<_BlockElement> _blocks = <_BlockElement>[]; final List<_TableElement> _tables = <_TableElement>[]; final List<_InlineElement> _inlines = <_InlineElement>[]; final List _linkHandlers = []; String? _currentBlockTag; String? _lastVisitedTag; bool _isInBlockquote = false; /// Returns widgets that display the given Markdown nodes. /// /// The returned widgets are typically used as children in a [ListView]. List build(List nodes) { _listIndents.clear(); _blocks.clear(); _tables.clear(); _inlines.clear(); _linkHandlers.clear(); _isInBlockquote = false; builders.forEach((String key, MarkdownElementBuilder value) { if (value.isBlockElement()) { _kBlockTags.add(key); } }); _blocks.add(_BlockElement(null)); for (final md.Node node in nodes) { assert(_blocks.length == 1); node.accept(this); } _addAnonymousBlockIfNeeded(); assert(_tables.isEmpty); assert(_inlines.isEmpty); assert(!_isInBlockquote); return _blocks.single.children; } @override bool visitElementBefore(md.Element element) { final String tag = element.tag; _currentBlockTag ??= tag; _lastVisitedTag = tag; if (builders.containsKey(tag)) { builders[tag]!.visitElementBefore(element); } if (paddingBuilders.containsKey(tag)) { paddingBuilders[tag]!.visitElementBefore(element); } int? start; final bool isBlock = _isBlockTag(tag) || (builders.containsKey(tag) && builders[tag]!.isBlockElement()); if (isBlock) { _addAnonymousBlockIfNeeded(); if (_isListTag(tag)) { _listIndents.add(tag); if (element.attributes['start'] != null) { start = int.parse(element.attributes['start']!) - 1; } } else if (tag == 'blockquote') { _isInBlockquote = true; } else if (tag == 'table') { _tables.add(_TableElement()); } else if (tag == 'tr') { final int length = _tables.single.rows.length; Decoration? decoration = styleSheet.tableCellsDecoration; if (length == 0) { decoration = styleSheet.tableHeadCellsDecoration ?? styleSheet.tableCellsDecoration; } else if (length.isEven) { decoration = styleSheet.tableCellsDecoration; } else { decoration = null; } _tables.single.rows.add(TableRow( decoration: decoration, // TODO(stuartmorgan): This should be fixed, not suppressed; enabling // this lint warning exposed that the builder is modifying the // children of TableRows, even though they are @immutable. // ignore: prefer_const_literals_to_create_immutables children: [], )); } final _BlockElement bElement = _BlockElement(tag); if (start != null) { bElement.nextListIndex = start; } _blocks.add(bElement); } else { if (tag == 'a' && !builders.containsKey('a')) { final String? text = extractTextFromElement(element); // Don't add empty links if (text == null) { return false; } final String? destination = element.attributes['href']; final String title = element.attributes['title'] ?? ''; _linkHandlers.add( delegate.createLink(text, destination, title), ); } _addParentInlineIfNeeded(_blocks.last.tag); // The Markdown parser passes empty table data tags for blank // table cells. Insert a text node with an empty string in this // case for the table cell to get properly created. if (element.tag == 'td' && element.children != null && element.children!.isEmpty) { element.children!.add(md.Text('')); } final TextStyle? parentStyle = _inlines.last.style; final TextStyle? tagStyle = styleSheet.styles[tag]; _inlines.add(_InlineElement(tag, style: parentStyle != null ? parentStyle.merge(tagStyle) : tagStyle)); } return true; } /// Returns the text, if any, from [element] and its descendants. String? extractTextFromElement(md.Node element) { return element is md.Element && (element.children?.isNotEmpty ?? false) ? element.children!.map((md.Node e) => e is md.Text ? e.text : extractTextFromElement(e)).join() : (element is md.Element && (element.attributes.isNotEmpty) ? element.attributes['alt'] : ''); } @override void visitText(md.Text text) { // Don't allow text directly under the root. if (_blocks.last.tag == null) { return; } _addParentInlineIfNeeded(_blocks.last.tag); // Define trim text function to remove spaces from text elements in // accordance with Markdown specifications. String trimText(String text) { // The leading spaces pattern is used to identify spaces // at the beginning of a line of text. final RegExp leadingSpacesPattern = RegExp(r'^ *'); // The soft line break is used to identify the spaces at the end of a line // of text and the leading spaces in the immediately following the line // of text. These spaces are removed in accordance with the Markdown // specification on soft line breaks when lines of text are joined. final RegExp softLineBreakPattern = RegExp(r' ?\n *'); // Leading spaces following a hard line break are ignored. // https://github.github.com/gfm/#example-657 // Leading spaces in paragraph or list item are ignored // https://github.github.com/gfm/#example-192 // https://github.github.com/gfm/#example-236 if (const ['ul', 'ol', 'li', 'p', 'br'].contains(_lastVisitedTag)) { text = text.replaceAll(leadingSpacesPattern, ''); } if (softLineBreak) { return text; } return text.replaceAll(softLineBreakPattern, ' '); } Widget? child; if (_blocks.isNotEmpty && builders.containsKey(_blocks.last.tag)) { child = builders[_blocks.last.tag!]!.visitText(text, styleSheet.styles[_blocks.last.tag!]); } else if (_blocks.last.tag == 'pre') { child = _ScrollControllerBuilder( builder: (BuildContext context, ScrollController preScrollController, Widget? child) { return Scrollbar( controller: preScrollController, child: SingleChildScrollView( controller: preScrollController, scrollDirection: Axis.horizontal, padding: styleSheet.codeblockPadding, child: child, ), ); }, child: _buildRichText(delegate.formatText(styleSheet, text.text))); } else { child = _buildRichText( TextSpan( style: _isInBlockquote ? styleSheet.blockquote : _inlines.last.style, text: trimText(text.text), recognizer: _linkHandlers.isNotEmpty ? _linkHandlers.last : null, ), textAlign: _textAlignForBlockTag(_currentBlockTag), ); } if (child != null) { _inlines.last.children.add(child); } _lastVisitedTag = null; } @override void visitElementAfter(md.Element element) { final String tag = element.tag; final bool isBlock = _isBlockTag(tag) || (builders.containsKey(tag) && builders[tag]!.isBlockElement()); if (isBlock) { _addAnonymousBlockIfNeeded(); final _BlockElement current = _blocks.removeLast(); Widget defaultChild() { if (current.children.isNotEmpty) { return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: fitContent ? CrossAxisAlignment.start : CrossAxisAlignment.stretch, children: current.children, ); } else { return const SizedBox(); } } Widget child = builders[tag]?.visitElementAfterWithContext( delegate.context, element, styleSheet.styles[tag], _inlines.isNotEmpty ? _inlines.last.style : null, ) ?? defaultChild(); if (_isListTag(tag)) { assert(_listIndents.isNotEmpty); _listIndents.removeLast(); } else if (tag == 'li') { if (_listIndents.isNotEmpty) { if (element.children!.isEmpty) { element.children!.add(md.Text('')); } Widget bullet; final dynamic el = element.children![0]; if (el is md.Element && el.attributes['type'] == 'checkbox') { final bool val = el.attributes.containsKey('checked'); bullet = _buildCheckbox(val); } else { bullet = _buildBullet(_listIndents.last); } child = Row( mainAxisSize: fitContent ? MainAxisSize.min : MainAxisSize.max, textBaseline: listItemCrossAxisAlignment == MarkdownListItemCrossAxisAlignment.start ? null : TextBaseline.alphabetic, crossAxisAlignment: listItemCrossAxisAlignment == MarkdownListItemCrossAxisAlignment.start ? CrossAxisAlignment.start : CrossAxisAlignment.baseline, children: [ SizedBox( width: styleSheet.listIndent! + styleSheet.listBulletPadding!.left + styleSheet.listBulletPadding!.right, child: bullet, ), Flexible( fit: fitContent ? FlexFit.loose : FlexFit.tight, child: child, ) ], ); } } else if (tag == 'table') { if (styleSheet.tableColumnWidth is FixedColumnWidth || styleSheet.tableColumnWidth is IntrinsicColumnWidth) { child = _ScrollControllerBuilder( builder: (BuildContext context, ScrollController tableScrollController, Widget? child) { return Scrollbar( controller: tableScrollController, thumbVisibility: styleSheet.tableScrollbarThumbVisibility, child: SingleChildScrollView( controller: tableScrollController, scrollDirection: Axis.horizontal, padding: styleSheet.tablePadding, child: child, ), ); }, child: _buildTable(), ); } else { child = _buildTable(); } } else if (tag == 'blockquote') { _isInBlockquote = false; child = DecoratedBox( decoration: styleSheet.blockquoteDecoration!, child: Padding( padding: styleSheet.blockquotePadding!, child: child, ), ); } else if (tag == 'pre') { child = Container( clipBehavior: Clip.hardEdge, decoration: styleSheet.codeblockDecoration, child: child, ); } else if (tag == 'hr') { child = Container(decoration: styleSheet.horizontalRuleDecoration); } _addBlockChild(child); } else { final _InlineElement current = _inlines.removeLast(); final _InlineElement parent = _inlines.last; EdgeInsets padding = EdgeInsets.zero; if (paddingBuilders.containsKey(tag)) { padding = paddingBuilders[tag]!.getPadding(); } if (builders.containsKey(tag)) { final Widget? child = builders[tag]!.visitElementAfterWithContext( delegate.context, element, styleSheet.styles[tag], parent.style, ); if (child != null) { if (current.children.isEmpty) { current.children.add(child); } else { current.children[0] = child; } } } else if (tag == 'img') { // create an image widget for this image current.children.add(_buildPadding( padding, _buildImage( element.attributes['src']!, element.attributes['title'], element.attributes['alt'], ), )); } else if (tag == 'br') { current.children.add(_buildRichText(const TextSpan(text: '\n'))); } else if (tag == 'th' || tag == 'td') { final bool isHeaderCell = tag == 'th'; TextAlign? align; final String? alignAttribute = element.attributes['align']; if (alignAttribute == null) { align = tag == 'th' ? styleSheet.tableHeadAlign : TextAlign.left; } else { switch (alignAttribute) { case 'left': align = TextAlign.left; case 'center': align = TextAlign.center; case 'right': align = TextAlign.right; } } final Widget child = _buildTableCell( _mergeInlineChildren(current.children, align), textAlign: align, isHeader: isHeaderCell, ); _tables.single.rows.last.children.add(child); } else if (tag == 'a') { if (!builders.containsKey('a')) { _linkHandlers.removeLast(); } } else if (tag == 'sup') { final Widget c = current.children.last; TextSpan? textSpan; if (c is Text && c.textSpan is TextSpan) { textSpan = c.textSpan! 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'), if (styleSheet.superscriptFontFeatureTag != null) FontFeature.enable(styleSheet.superscriptFontFeatureTag!), ], ), ), ); current.children.removeLast(); current.children.add(richText); } } if (current.children.isNotEmpty) { parent.children.addAll(current.children); } } if (_currentBlockTag == tag) { _currentBlockTag = null; } _lastVisitedTag = tag; } Widget _buildTable() { final Table table = Table( defaultColumnWidth: styleSheet.tableColumnWidth!, defaultVerticalAlignment: styleSheet.tableVerticalAlignment, border: styleSheet.tableBorder, children: _tables.removeLast().rows, ); // Clip the table to the border radius if one is specified final BorderRadiusGeometry? borderRadius = styleSheet.tableBorder?.borderRadius; if (borderRadius != null) { return ClipRRect( borderRadius: borderRadius, child: table, ); } return table; } Widget _buildImage(String src, String? title, String? alt) { final List parts = src.split('#'); if (parts.isEmpty) { return const SizedBox(); } final String path = parts.first; double? width; double? height; if (parts.length == 2) { final List dimensions = parts.last.split('x'); if (dimensions.length == 2) { width = double.tryParse(dimensions[0]); height = double.tryParse(dimensions[1]); } } final Uri? uri = Uri.tryParse(path); if (uri == null) { return const SizedBox(); } Widget child; if (imageBuilder != null) { child = imageBuilder!(uri, title, alt); } else { child = kDefaultImageBuilder(uri, imageDirectory, width, height); } if (_linkHandlers.isNotEmpty) { final TapGestureRecognizer recognizer = _linkHandlers.last as TapGestureRecognizer; return GestureDetector(onTap: recognizer.onTap, child: child); } else { return child; } } Widget _buildCheckbox(bool checked) { if (checkboxBuilder != null) { return checkboxBuilder!(checked); } return Padding( padding: styleSheet.listBulletPadding!, child: Icon( checked ? Icons.check_box : Icons.check_box_outline_blank, size: styleSheet.checkbox!.fontSize, color: styleSheet.checkbox!.color, ), ); } Widget _buildBullet(String listTag) { final int index = _blocks.last.nextListIndex; final bool isUnordered = listTag == 'ul'; if (bulletBuilder != null) { return Padding( padding: styleSheet.listBulletPadding!, child: bulletBuilder!( MarkdownBulletParameters( index: index, style: isUnordered ? BulletStyle.unorderedList : BulletStyle.orderedList, nestLevel: _listIndents.length - 1, ), ), ); } if (isUnordered) { return Padding( padding: styleSheet.listBulletPadding!, child: Text( '•', textAlign: TextAlign.center, style: styleSheet.listBullet, ), ); } return Padding( padding: styleSheet.listBulletPadding!, child: Text( '${index + 1}.', textAlign: TextAlign.right, style: styleSheet.listBullet, ), ); } Widget _buildTableCell(List children, {TextAlign? textAlign, bool isHeader = false}) { final EdgeInsets cellPadding = isHeader && styleSheet.tableHeadCellsPadding != null ? styleSheet.tableHeadCellsPadding! : styleSheet.tableCellsPadding!; final TextStyle cellStyle = isHeader && styleSheet.tableHead != null ? styleSheet.tableHead! : styleSheet.tableBody!; return TableCell( child: Padding( padding: cellPadding, child: DefaultTextStyle( style: cellStyle, textAlign: textAlign, child: Wrap( alignment: switch (textAlign) { TextAlign.left => WrapAlignment.start, TextAlign.center => WrapAlignment.center, TextAlign.right => WrapAlignment.end, _ => WrapAlignment.start, }, children: children as List, ), ), ), ); } Widget _buildPadding(EdgeInsets padding, Widget child) { if (padding == EdgeInsets.zero) { return child; } return Padding(padding: padding, child: child); } void _addParentInlineIfNeeded(String? tag) { if (_inlines.isEmpty) { _inlines.add(_InlineElement( tag, style: tag != null ? styleSheet.styles[tag] : null, )); } } void _addBlockChild(Widget child) { final _BlockElement parent = _blocks.last; if (parent.children.isNotEmpty) { parent.children.add(SizedBox(height: styleSheet.blockSpacing)); } parent.children.add(child); parent.nextListIndex += 1; } void _addAnonymousBlockIfNeeded() { if (_inlines.isEmpty) { return; } WrapAlignment blockAlignment = WrapAlignment.start; TextAlign textAlign = TextAlign.start; EdgeInsets textPadding = EdgeInsets.zero; if (_isBlockTag(_currentBlockTag)) { blockAlignment = _wrapAlignmentForBlockTag(_currentBlockTag); textAlign = _textAlignForBlockTag(_currentBlockTag); textPadding = _textPaddingForBlockTag(_currentBlockTag); if (paddingBuilders.containsKey(_currentBlockTag)) { textPadding = paddingBuilders[_currentBlockTag]!.getPadding(); } } final _InlineElement inline = _inlines.single; if (inline.children.isNotEmpty) { final List mergedInlines = _mergeInlineChildren( inline.children, textAlign, ); final Wrap wrap = Wrap( crossAxisAlignment: WrapCrossAlignment.center, alignment: blockAlignment, children: mergedInlines, ); if (textPadding == EdgeInsets.zero) { _addBlockChild(wrap); } else { final Padding padding = Padding(padding: textPadding, child: wrap); _addBlockChild(padding); } } _inlines.clear(); } /// Extracts all spans from an inline element and merges them into a single list Iterable _getInlineSpansFromSpan(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; } // Accesses the TextSpan property correctly depending on the widget type. // Returns null if not a valid (text) widget. InlineSpan? _getInlineSpanFromText(Widget widget) => switch (widget) { SelectableText() => widget.textSpan, Text() => widget.textSpan, RichText() => widget.text, _ => null }; /// Merges adjacent [TextSpan] children. /// Also forces a specific [TextAlign] regardless of merging. /// This is essential for table column alignment, since desired column alignment /// is discovered after the text widgets have been created. This function is the /// last chance to enforce the desired column alignment in the texts. List _mergeInlineChildren( List children, TextAlign? textAlign, ) { // List of text widgets (merged) and non-text widgets (non-merged) final List mergedWidgets = []; bool lastIsText = false; for (final Widget child in children) { final InlineSpan? currentSpan = _getInlineSpanFromText(child); final bool currentIsText = currentSpan != null; if (!currentIsText) { // There is no merging to do, so just add and continue mergedWidgets.add(child); lastIsText = false; continue; } // Extracted spans from the last and the current widget List spans = []; if (lastIsText) { // Removes last widget from the list for merging and extracts its spans spans.addAll(_getInlineSpansFromSpan(_getInlineSpanFromText(mergedWidgets.removeLast())!)); } spans.addAll(_getInlineSpansFromSpan(currentSpan)); spans = _mergeSimilarTextSpans(spans); final Widget mergedWidget; if (spans.isEmpty) { // no spans found, just insert the current widget mergedWidget = child; } else { final InlineSpan first = spans.first; final TextSpan textSpan = (spans.length == 1 && first is TextSpan) ? first : TextSpan(children: spans); mergedWidget = _buildRichText(textSpan, textAlign: textAlign); } mergedWidgets.add(mergedWidget); lastIsText = true; } return mergedWidgets; } TextAlign _textAlignForBlockTag(String? blockTag) { final WrapAlignment wrapAlignment = _wrapAlignmentForBlockTag(blockTag); switch (wrapAlignment) { case WrapAlignment.start: return TextAlign.start; case WrapAlignment.center: return TextAlign.center; case WrapAlignment.end: return TextAlign.end; case WrapAlignment.spaceAround: return TextAlign.justify; case WrapAlignment.spaceBetween: return TextAlign.justify; case WrapAlignment.spaceEvenly: return TextAlign.justify; } } WrapAlignment _wrapAlignmentForBlockTag(String? blockTag) { switch (blockTag) { case 'p': return styleSheet.textAlign; case 'h1': return styleSheet.h1Align; case 'h2': return styleSheet.h2Align; case 'h3': return styleSheet.h3Align; case 'h4': return styleSheet.h4Align; case 'h5': return styleSheet.h5Align; case 'h6': return styleSheet.h6Align; case 'ul': return styleSheet.unorderedListAlign; case 'ol': return styleSheet.orderedListAlign; case 'blockquote': return styleSheet.blockquoteAlign; case 'pre': return styleSheet.codeblockAlign; case 'hr': break; case 'li': break; } return WrapAlignment.start; } EdgeInsets _textPaddingForBlockTag(String? blockTag) { switch (blockTag) { case 'p': return styleSheet.pPadding!; case 'h1': return styleSheet.h1Padding!; case 'h2': return styleSheet.h2Padding!; case 'h3': return styleSheet.h3Padding!; case 'h4': return styleSheet.h4Padding!; case 'h5': return styleSheet.h5Padding!; case 'h6': return styleSheet.h6Padding!; } return EdgeInsets.zero; } /// Combine text spans with equivalent properties into a single span. List _mergeSimilarTextSpans(List textSpans) { if (textSpans.length < 2) { return textSpans; } final List mergedSpans = []; for (int index = 1; index < textSpans.length; index++) { 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, semanticsLabel: previous.semanticsLabel, style: previous.style, )); } else { 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; } Widget _buildRichText(TextSpan text, {TextAlign? textAlign, String? key}) { //Adding a unique key prevents the problem of using the same link handler for text spans with the same text final Key k = key == null ? UniqueKey() : Key(key); if (selectable) { return SelectableText.rich( text, textScaler: styleSheet.textScaler, textAlign: textAlign ?? TextAlign.start, onSelectionChanged: onSelectionChanged != null ? (TextSelection selection, SelectionChangedCause? cause) => onSelectionChanged!(text.text, selection, cause) : null, onTap: onTapText, key: k, ); } else { return Text.rich( text, textScaler: styleSheet.textScaler, textAlign: textAlign ?? TextAlign.start, key: k, ); } } } class _ScrollControllerBuilder extends StatefulWidget { const _ScrollControllerBuilder({ required this.builder, this.child, }); final ValueWidgetBuilder builder; final Widget? child; @override State<_ScrollControllerBuilder> createState() => _ScrollControllerBuilderState(); } class _ScrollControllerBuilderState extends State<_ScrollControllerBuilder> { final ScrollController _controller = ScrollController(); @override void dispose() { _controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return widget.builder(context, _controller, widget.child); } }