From 2ffa1ddabb3f2a660ab85c551255b89fe8a24ab5 Mon Sep 17 00:00:00 2001 From: Matthew Whitaker Date: Mon, 15 May 2023 10:58:12 -0600 Subject: [PATCH] feat: Add WrapperExtension helper (#1264) --- example/lib/main.dart | 8 ++ lib/src/builtins/details_element_builtin.dart | 4 +- lib/src/builtins/image_builtin.dart | 2 +- .../builtins/interactive_element_builtin.dart | 4 +- lib/src/builtins/ruby_builtin.dart | 2 +- lib/src/builtins/styled_element_builtin.dart | 6 +- lib/src/builtins/text_builtin.dart | 2 +- lib/src/builtins/vertical_align_builtin.dart | 4 +- .../extension/helpers/image_extension.dart | 4 +- .../helpers/image_tap_extension.dart | 4 +- .../extension/helpers/matcher_extension.dart | 2 +- lib/src/extension/helpers/tag_extension.dart | 11 +- .../extension/helpers/tag_wrap_extension.dart | 85 ++++++++++++++ lib/src/extension/html_extension.dart | 3 +- lib/src/html_parser.dart | 91 ++++++++++----- .../lib/flutter_html_audio.dart | 2 +- .../lib/flutter_html_iframe.dart | 2 +- .../lib/flutter_html_math.dart | 2 +- .../lib/flutter_html_svg.dart | 2 +- .../lib/flutter_html_table.dart | 6 +- .../lib/flutter_html_video.dart | 2 +- test/extension/wrapperextension_test.dart | 110 ++++++++++++++++++ 22 files changed, 296 insertions(+), 62 deletions(-) create mode 100644 lib/src/extension/helpers/tag_wrap_extension.dart create mode 100644 test/extension/wrapperextension_test.dart diff --git a/example/lib/main.dart b/example/lib/main.dart index e6c0a43def..db394d8ed0 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -317,6 +317,14 @@ class MyHomePageState extends State { ), }, extensions: [ + TagWrapExtension( + tagsToWrap: {"table"}, + builder: (child) { + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: child, + ); + }), TagExtension( tagsToExtend: {"tex"}, builder: (context) => Math.tex( diff --git a/lib/src/builtins/details_element_builtin.dart b/lib/src/builtins/details_element_builtin.dart index 211a52f432..d3a48a7db6 100644 --- a/lib/src/builtins/details_element_builtin.dart +++ b/lib/src/builtins/details_element_builtin.dart @@ -24,8 +24,8 @@ class DetailsElementBuiltIn extends HtmlExtension { @override InlineSpan build(ExtensionContext context, - Map Function() parseChildren) { - final childList = parseChildren(); + Map Function() buildChildren) { + final childList = buildChildren(); final children = childList.values; InlineSpan? firstChild = children.isNotEmpty ? children.first : null; diff --git a/lib/src/builtins/image_builtin.dart b/lib/src/builtins/image_builtin.dart index 6e56e7d2f8..89da7c8589 100644 --- a/lib/src/builtins/image_builtin.dart +++ b/lib/src/builtins/image_builtin.dart @@ -72,7 +72,7 @@ class ImageBuiltIn extends HtmlExtension { @override InlineSpan build(ExtensionContext context, - Map Function() parseChildren) { + Map Function() buildChildren) { final element = context.styledElement as ImageElement; final imageStyle = Style( diff --git a/lib/src/builtins/interactive_element_builtin.dart b/lib/src/builtins/interactive_element_builtin.dart index d62ccd72f5..19b66ff4fb 100644 --- a/lib/src/builtins/interactive_element_builtin.dart +++ b/lib/src/builtins/interactive_element_builtin.dart @@ -39,9 +39,9 @@ class InteractiveElementBuiltIn extends HtmlExtension { @override InlineSpan build(ExtensionContext context, - Map Function() parseChildren) { + Map Function() buildChildren) { return TextSpan( - children: parseChildren().values.map((childSpan) { + children: buildChildren().values.map((childSpan) { return _processInteractableChild(context, childSpan); }).toList(), ); diff --git a/lib/src/builtins/ruby_builtin.dart b/lib/src/builtins/ruby_builtin.dart index 22df92d410..f91b8c5279 100644 --- a/lib/src/builtins/ruby_builtin.dart +++ b/lib/src/builtins/ruby_builtin.dart @@ -40,7 +40,7 @@ class RubyBuiltIn extends HtmlExtension { @override InlineSpan build(ExtensionContext context, - Map Function() parseChildren) { + Map Function() buildChildren) { StyledElement? node; List widgets = []; final rubySize = context.parser.style['rt']?.fontSize?.value ?? diff --git a/lib/src/builtins/styled_element_builtin.dart b/lib/src/builtins/styled_element_builtin.dart index 5fb9f1a16b..6dad7de21e 100644 --- a/lib/src/builtins/styled_element_builtin.dart +++ b/lib/src/builtins/styled_element_builtin.dart @@ -409,7 +409,7 @@ class StyledElementBuiltIn extends HtmlExtension { @override InlineSpan build(ExtensionContext context, - Map Function() parseChildren) { + Map Function() buildChildren) { if (context.styledElement!.style.display == Display.listItem || ((context.styledElement!.style.display == Display.block || context.styledElement!.style.display == Display.inlineBlock) && @@ -424,7 +424,7 @@ class StyledElementBuiltIn extends HtmlExtension { shrinkWrap: context.parser.shrinkWrap, childIsReplaced: ["iframe", "img", "video", "audio"] .contains(context.styledElement!.name), - children: parseChildren() + children: buildChildren() .entries .expandIndexed((i, child) => [ child.value, @@ -441,7 +441,7 @@ class StyledElementBuiltIn extends HtmlExtension { return TextSpan( style: context.styledElement!.style.generateTextStyle(), - children: parseChildren() + children: buildChildren() .entries .expand((child) => [ child.value, diff --git a/lib/src/builtins/text_builtin.dart b/lib/src/builtins/text_builtin.dart index 7f51473514..2e170d30d5 100644 --- a/lib/src/builtins/text_builtin.dart +++ b/lib/src/builtins/text_builtin.dart @@ -44,7 +44,7 @@ class TextBuiltIn extends HtmlExtension { @override InlineSpan build(ExtensionContext context, - Map Function() parseChildren) { + Map Function() buildChildren) { final element = context.styledElement! as TextContentElement; return TextSpan( style: element.style.generateTextStyle(), diff --git a/lib/src/builtins/vertical_align_builtin.dart b/lib/src/builtins/vertical_align_builtin.dart index cba71485c7..81617c37f4 100644 --- a/lib/src/builtins/vertical_align_builtin.dart +++ b/lib/src/builtins/vertical_align_builtin.dart @@ -23,12 +23,12 @@ class VerticalAlignBuiltIn extends HtmlExtension { } @override - InlineSpan build(ExtensionContext context, parseChildren) { + InlineSpan build(ExtensionContext context, buildChildren) { return WidgetSpan( child: Transform.translate( offset: Offset(0, _getVerticalOffset(context.styledElement!)), child: CssBoxWidget.withInlineSpanChildren( - children: parseChildren().values.toList(), + children: buildChildren().values.toList(), style: context.styledElement!.style, ), ), diff --git a/lib/src/extension/helpers/image_extension.dart b/lib/src/extension/helpers/image_extension.dart index 0a1c6b13be..373c4cf22c 100644 --- a/lib/src/extension/helpers/image_extension.dart +++ b/lib/src/extension/helpers/image_extension.dart @@ -59,11 +59,11 @@ class ImageExtension extends ImageBuiltIn { } @override - InlineSpan build(ExtensionContext context, parseChildren) { + InlineSpan build(ExtensionContext context, buildChildren) { if (builder != null) { return builder!.call(context); } else { - return super.build(context, parseChildren); + return super.build(context, buildChildren); } } } diff --git a/lib/src/extension/helpers/image_tap_extension.dart b/lib/src/extension/helpers/image_tap_extension.dart index 950eecb614..1d7bcf7491 100644 --- a/lib/src/extension/helpers/image_tap_extension.dart +++ b/lib/src/extension/helpers/image_tap_extension.dart @@ -44,8 +44,8 @@ class OnImageTapExtension extends ImageBuiltIn { } @override - InlineSpan build(ExtensionContext context, parseChildren) { - final children = parseChildren(); + InlineSpan build(ExtensionContext context, buildChildren) { + final children = buildChildren(); assert(children.keys.isNotEmpty, "The OnImageTapExtension has been thwarted! It no longer has an `img` child"); diff --git a/lib/src/extension/helpers/matcher_extension.dart b/lib/src/extension/helpers/matcher_extension.dart index 4c37ef4d53..c0b96ba823 100644 --- a/lib/src/extension/helpers/matcher_extension.dart +++ b/lib/src/extension/helpers/matcher_extension.dart @@ -38,7 +38,7 @@ class MatcherExtension extends HtmlExtension { } @override - InlineSpan build(ExtensionContext context, parseChildren) { + InlineSpan build(ExtensionContext context, buildChildren) { return builder(context); } } diff --git a/lib/src/extension/helpers/tag_extension.dart b/lib/src/extension/helpers/tag_extension.dart index 9afe98fa7f..e36a42c910 100644 --- a/lib/src/extension/helpers/tag_extension.dart +++ b/lib/src/extension/helpers/tag_extension.dart @@ -8,7 +8,10 @@ class TagExtension extends HtmlExtension { late final InlineSpan Function(ExtensionContext) builder; /// [TagExtension] allows you to extend the functionality of flutter_html - /// by defining the behavior of custom tags to return a child widget. + /// by defining a mapping from a custom or existing tag to a widget. + /// + /// If instead you'd like to wrap a tag (or custom tag) in a widget, + /// see [TagWrapExtension]. TagExtension({ required this.tagsToExtend, Widget? child, @@ -23,8 +26,8 @@ class TagExtension extends HtmlExtension { } /// [TagExtension.inline] allows you to extend the functionality of - /// flutter_html by defining the behavior of custom tags to return - /// a child InlineSpan. + /// flutter_html by defining a mapping from a custom or existing tag + /// to an InlineSpan. TagExtension.inline({ required this.tagsToExtend, InlineSpan? child, @@ -42,7 +45,7 @@ class TagExtension extends HtmlExtension { Set get supportedTags => tagsToExtend; @override - InlineSpan build(ExtensionContext context, parseChildren) { + InlineSpan build(ExtensionContext context, buildChildren) { return builder(context); } } diff --git a/lib/src/extension/helpers/tag_wrap_extension.dart b/lib/src/extension/helpers/tag_wrap_extension.dart new file mode 100644 index 0000000000..827729dd0b --- /dev/null +++ b/lib/src/extension/helpers/tag_wrap_extension.dart @@ -0,0 +1,85 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_html/src/css_box_widget.dart'; +import 'package:flutter_html/src/extension/html_extension.dart'; +import 'package:flutter_html/src/style.dart'; +import 'package:flutter_html/src/tree/styled_element.dart'; +import 'package:html/dom.dart' as html; + +class TagWrapExtension extends HtmlExtension { + final Set tagsToWrap; + final Widget Function(Widget child) builder; + + /// [TagWrapExtension] allows you to easily wrap a specific tag (or tags) + /// in another element. For example, you could wrap `` in a + /// `SingleChildScrollView`: + /// + /// ```dart + /// extensions: [ + /// WrapperExtension( + /// tagsToWrap: {"table"}, + /// builder: (child) { + /// return SingleChildScrollView( + /// scrollDirection: Axis.horizontal, + /// child: child, + /// ); + /// }, + /// ), + /// ], + /// ``` + TagWrapExtension({ + required this.tagsToWrap, + required this.builder, + }); + + @override + Set get supportedTags => tagsToWrap; + + @override + bool matches(ExtensionContext context) { + switch (context.currentStep) { + case CurrentStep.preparing: + return super.matches(context); + case CurrentStep.preStyling: + case CurrentStep.preProcessing: + return false; + case CurrentStep.building: + return context.styledElement is WrapperElement; + } + } + + @override + StyledElement prepare( + ExtensionContext context, List children) { + return WrapperElement( + child: context.parser.prepareFromExtension( + context, + children, + extensionsToIgnore: {this}, + ), + ); + } + + @override + InlineSpan build(ExtensionContext context, buildChildren) { + final children = buildChildren(); + final child = CssBoxWidget.withInlineSpanChildren( + children: children.values.toList(), + style: context.styledElement!.style, + ); + + return WidgetSpan( + child: builder.call(child), + ); + } +} + +class WrapperElement extends StyledElement { + WrapperElement({ + required StyledElement child, + }) : super( + node: html.Element.tag("wrapper-element"), + style: Style(), + children: [child], + name: "[wrapper-element]", + ); +} diff --git a/lib/src/extension/html_extension.dart b/lib/src/extension/html_extension.dart index 5ad4faee6e..85f1dccca5 100644 --- a/lib/src/extension/html_extension.dart +++ b/lib/src/extension/html_extension.dart @@ -8,6 +8,7 @@ export 'package:flutter_html/src/extension/helpers/tag_extension.dart'; export 'package:flutter_html/src/extension/helpers/matcher_extension.dart'; export 'package:flutter_html/src/extension/helpers/image_extension.dart'; export 'package:flutter_html/src/extension/helpers/image_tap_extension.dart'; +export 'package:flutter_html/src/extension/helpers/tag_wrap_extension.dart'; /// The [HtmlExtension] class allows you to customize the behavior of flutter_html /// or add additional functionality. @@ -58,7 +59,7 @@ abstract class HtmlExtension { /// attached `Style` elements, into an `InlineSpan` tree that includes /// Widget/TextSpans that can be rendered in a RichText widget. InlineSpan build(ExtensionContext context, - Map Function() parseChildren) { + Map Function() buildChildren) { throw UnimplementedError( "Extension `$runtimeType` matched `${context.styledElement!.name}` but didn't implement `parse`"); } diff --git a/lib/src/html_parser.dart b/lib/src/html_parser.dart index 7184a8b993..84e8a9d6a0 100644 --- a/lib/src/html_parser.dart +++ b/lib/src/html_parser.dart @@ -92,6 +92,61 @@ class HtmlParser extends StatefulWidget { } onLinkTap?.call(url, attributes, element); }; + + /// Prepares the html node using one of the built-ins or HtmlExtensions + /// available. If none of the extensions matches, returns an + /// EmptyContentElement + StyledElement prepareFromExtension( + ExtensionContext extensionContext, + List children, { + Set extensionsToIgnore = const {}, + }) { + // Loop through every extension and see if it can handle this node + for (final extension in extensions) { + if (!extensionsToIgnore.contains(extension) && + extension.matches(extensionContext)) { + return extension.prepare(extensionContext, children); + } + } + + // Loop through built in elements and see if they can handle this node. + for (final builtIn in builtIns) { + if (!extensionsToIgnore.contains(builtIn) && + builtIn.matches(extensionContext)) { + return builtIn.prepare(extensionContext, children); + } + } + + // If no extension or built-in matches, then return an empty content element. + return EmptyContentElement(node: extensionContext.node); + } + + /// Builds the StyledElement into an InlineSpan using one of the built-ins + /// or HtmlExtensions available. If none of the extensions matches, returns + /// an empty TextSpan. + InlineSpan buildFromExtension( + ExtensionContext extensionContext, + Map Function() buildChildren, { + Set extensionsToIgnore = const {}, + }) { + // Loop through every extension and see if it can handle this node + for (final extension in extensions) { + if (!extensionsToIgnore.contains(extension) && + extension.matches(extensionContext)) { + return extension.build(extensionContext, buildChildren); + } + } + + // Loop through built in elements and see if they can handle this node. + for (final builtIn in builtIns) { + if (!extensionsToIgnore.contains(builtIn) && + builtIn.matches(extensionContext)) { + return builtIn.build(extensionContext, buildChildren); + } + } + + return const TextSpan(text: ""); + } } class _HtmlParserState extends State { @@ -189,22 +244,8 @@ class _HtmlParserState extends State { // Lex this element's children final children = node.nodes.map(_prepareHtmlTreeRecursive).toList(); - // Loop through every extension and see if it can handle this node - for (final extension in widget.extensions) { - if (extension.matches(extensionContext)) { - return extension.prepare(extensionContext, children); - } - } - - // Loop through built in elements and see if they can handle this node. - for (final builtIn in HtmlParser.builtIns) { - if (builtIn.matches(extensionContext)) { - return builtIn.prepare(extensionContext, children); - } - } - - // If no extension or built-in matches, then return an empty content element. - return EmptyContentElement(node: node); + // Prepare the element from one of the extensions + return widget.prepareFromExtension(extensionContext, children); } /// Called before any styling is cascaded on the tree @@ -353,26 +394,12 @@ class _HtmlParserState extends State { } // Generate a function that allows children to be generated - Map parseChildren() { + Map buildChildren() { return Map.fromEntries(tree.children.map((child) { return MapEntry(child, _buildTreeRecursive(child)); })); } - // Loop through every extension and see if it can handle this node - for (final extension in widget.extensions) { - if (extension.matches(extensionContext)) { - return extension.build(extensionContext, parseChildren); - } - } - - // Loop through built in elements and see if they can handle this node. - for (final builtIn in HtmlParser.builtIns) { - if (builtIn.matches(extensionContext)) { - return builtIn.build(extensionContext, parseChildren); - } - } - - return const TextSpan(text: ""); + return widget.buildFromExtension(extensionContext, buildChildren); } } diff --git a/packages/flutter_html_audio/lib/flutter_html_audio.dart b/packages/flutter_html_audio/lib/flutter_html_audio.dart index 3a123155bf..607da1812d 100644 --- a/packages/flutter_html_audio/lib/flutter_html_audio.dart +++ b/packages/flutter_html_audio/lib/flutter_html_audio.dart @@ -19,7 +19,7 @@ class AudioHtmlExtension extends HtmlExtension { Set get supportedTags => {"audio"}; @override - InlineSpan build(ExtensionContext context, parseChildren) { + InlineSpan build(ExtensionContext context, buildChildren) { return WidgetSpan( child: AudioWidget( context: context, diff --git a/packages/flutter_html_iframe/lib/flutter_html_iframe.dart b/packages/flutter_html_iframe/lib/flutter_html_iframe.dart index 317b4fb2a0..4522626528 100644 --- a/packages/flutter_html_iframe/lib/flutter_html_iframe.dart +++ b/packages/flutter_html_iframe/lib/flutter_html_iframe.dart @@ -19,7 +19,7 @@ class IframeHtmlExtension extends HtmlExtension { Set get supportedTags => {"iframe"}; @override - InlineSpan build(ExtensionContext context, parseChildren) { + InlineSpan build(ExtensionContext context, buildChildren) { return WidgetSpan( child: IframeWidget( extensionContext: context, diff --git a/packages/flutter_html_math/lib/flutter_html_math.dart b/packages/flutter_html_math/lib/flutter_html_math.dart index df558a1f2b..c4b5ea5ec7 100644 --- a/packages/flutter_html_math/lib/flutter_html_math.dart +++ b/packages/flutter_html_math/lib/flutter_html_math.dart @@ -18,7 +18,7 @@ class MathHtmlExtension extends HtmlExtension { Set get supportedTags => {"math"}; @override - InlineSpan build(ExtensionContext context, parseChildren) { + InlineSpan build(ExtensionContext context, buildChildren) { String texStr = _parseMathRecursive(context.styledElement!.element!, ''); return WidgetSpan( child: CssBoxWidget( diff --git a/packages/flutter_html_svg/lib/flutter_html_svg.dart b/packages/flutter_html_svg/lib/flutter_html_svg.dart index 1442841cfe..61fb0d97b3 100644 --- a/packages/flutter_html_svg/lib/flutter_html_svg.dart +++ b/packages/flutter_html_svg/lib/flutter_html_svg.dart @@ -133,7 +133,7 @@ class SvgHtmlExtension extends HtmlExtension { } @override - InlineSpan build(ExtensionContext context, parseChildren) { + InlineSpan build(ExtensionContext context, buildChildren) { late final Widget widget; if (context.elementName == "svg") { diff --git a/packages/flutter_html_table/lib/flutter_html_table.dart b/packages/flutter_html_table/lib/flutter_html_table.dart index c31623a8c7..a9bee64586 100644 --- a/packages/flutter_html_table/lib/flutter_html_table.dart +++ b/packages/flutter_html_table/lib/flutter_html_table.dart @@ -103,7 +103,7 @@ class TableHtmlExtension extends HtmlExtension { @override InlineSpan build(ExtensionContext context, - Map Function() parseChildren) { + Map Function() buildChildren) { if (context.elementName == "table") { return WidgetSpan( child: CssBoxWidget( @@ -112,7 +112,7 @@ class TableHtmlExtension extends HtmlExtension { builder: (_, constraints) { return _layoutCells( context.styledElement as TableElement, - parseChildren(), + buildChildren(), context, constraints, ); @@ -124,7 +124,7 @@ class TableHtmlExtension extends HtmlExtension { return WidgetSpan( child: CssBoxWidget.withInlineSpanChildren( - children: parseChildren().values.toList(), + children: buildChildren().values.toList(), style: Style(), ), ); diff --git a/packages/flutter_html_video/lib/flutter_html_video.dart b/packages/flutter_html_video/lib/flutter_html_video.dart index 1612109c99..1ab41638a3 100644 --- a/packages/flutter_html_video/lib/flutter_html_video.dart +++ b/packages/flutter_html_video/lib/flutter_html_video.dart @@ -21,7 +21,7 @@ class VideoHtmlExtension extends HtmlExtension { Set get supportedTags => {"video"}; @override - InlineSpan build(ExtensionContext context, parseChildren) { + InlineSpan build(ExtensionContext context, buildChildren) { return WidgetSpan( child: VideoWidget( context: context, diff --git a/test/extension/wrapperextension_test.dart b/test/extension/wrapperextension_test.dart new file mode 100644 index 0000000000..69f5ddb5e9 --- /dev/null +++ b/test/extension/wrapperextension_test.dart @@ -0,0 +1,110 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_html/flutter_html.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets( + "Check that widget renders a div normally", + (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Html( + data: "
Lorem ipsum dolor sit amet
", + ), + ), + ); + expect(find.text('Lorem ipsum dolor sit amet', findRichText: true), + findsOneWidget); + }, + ); + + const finderKey = Key("find-me"); + + testWidgets( + "Check that WrapperExtension doesn't match anything when given an empty set", + (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Html( + data: "
Lorem ipsum dolor sit amet
", + extensions: [ + TagWrapExtension( + tagsToWrap: {}, + builder: (child) => Container(key: finderKey, child: child), + ), + ], + ), + ), + ); + expect(find.text('Lorem ipsum dolor sit amet', findRichText: true), + findsOneWidget); + expect(find.byKey(finderKey), findsNothing); + }, + ); + + testWidgets( + "Check that WrapperExtension doesn't match anything when told to wrap a tag that isn't there", + (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Html( + data: "
Lorem ipsum dolor sit amet
", + extensions: [ + TagWrapExtension( + tagsToWrap: {"p"}, + builder: (child) => Container(key: finderKey, child: child), + ), + ], + ), + ), + ); + expect(find.text('Lorem ipsum dolor sit amet', findRichText: true), + findsOneWidget); + expect(find.byKey(finderKey), findsNothing); + }, + ); + + testWidgets( + "Check that WrapperExtension matches a normal div", + (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Html( + data: "
Lorem ipsum dolor sit amet
", + extensions: [ + TagWrapExtension( + tagsToWrap: {"div"}, + builder: (child) => Container(key: finderKey, child: child), + ), + ], + ), + ), + ); + expect(find.text('Lorem ipsum dolor sit amet', findRichText: true), + findsOneWidget); + expect(find.byKey(finderKey), findsOneWidget); + }, + ); + + testWidgets( + "Check that WrapperExtension doesn't render children unnecessarily", + (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Html( + data: "
Lorem ipsum dolor sit amet
", + extensions: [ + TagWrapExtension( + tagsToWrap: {"div"}, + builder: (child) => Container(key: finderKey), + ), + ], + ), + ), + ); + expect(find.text('Lorem ipsum dolor sit amet', findRichText: true), + findsNothing); + expect(find.byKey(finderKey), findsOneWidget); + }, + ); +}