diff --git a/README.md b/README.md index d4f49c7a9c..415cdcdf03 100644 --- a/README.md +++ b/README.md @@ -269,9 +269,13 @@ Inner links (such as `Back to the top` will work out of the b A powerful API that allows you to customize everything when rendering a specific HTML tag. This means you can change the default behaviour or add support for HTML elements that aren't supported natively. You can also make up your own custom tags in your HTML! -`customRender` accepts a `Map`. The `CustomRender` type is a function that requires a `Widget` or `InlineSpan` to be returned. It exposes `RenderContext` and the `Widget` that would have been rendered by `Html` without a `customRender` defined. The `RenderContext` contains the build context, styling and the HTML element, with attrributes and its subtree,. +`customRender` accepts a `Map`. -To use this API, set the key as the tag of the HTML element you wish to provide a custom implementation for, and create a function with the above parameters that returns a `Widget` or `InlineSpan`. +`CustomRenderMatcher` is a function that requires a `bool` to be returned. It exposes the `RenderContext` which provides `BuildContext` and access to the HTML tree. + +The `CustomRender` class has two constructors: `CustomRender.widget()` and `CustomRender.inlineSpan()`. Both require a ` Function(RenderContext, Function())`. The `Function()` argument is a function that will provide you with the element's children when needed. + +To use this API, create a matching function and an instance of `CustomRender`. Note: If you add any custom tags, you must add these tags to the [`tagsList`](#tagslist) parameter, otherwise they will not be rendered. See below for an example. @@ -286,21 +290,21 @@ Widget html = Html( """, customRender: { - "bird": (RenderContext context, Widget child) { - return TextSpan(text: "🐦"); - }, - "flutter": (RenderContext context, Widget child) { - return FlutterLogo( - style: (context.tree.element!.attributes['horizontal'] != null) - ? FlutterLogoStyle.horizontal - : FlutterLogoStyle.markOnly, - textColor: context.style.color!, - size: context.style.fontSize!.size! * 5, - ); - }, + birdMatcher(): CustomRender.inlineSpan(inlineSpan: (context, buildChildren) => TextSpan(text: "🐦")), + flutterMatcher(): CustomRender.widget(widget: (context, buildChildren) => FlutterLogo( + style: (context.tree.element!.attributes['horizontal'] != null) + ? FlutterLogoStyle.horizontal + : FlutterLogoStyle.markOnly, + textColor: context.style.color!, + size: context.style.fontSize!.size! * 5, + )), }, tagsList: Html.tags..addAll(["bird", "flutter"]), ); + +CustomRenderMatcher birdMatcher() => (context) => context.tree.element?.localName == 'bird'; + +CustomRenderMatcher flutterMatcher() => (context) => context.tree.element?.localName == 'flutter'; ``` 2. Complex example - wrapping the default widget with your own, in this case placing a horizontal scroll around a (potentially too wide) table. @@ -318,14 +322,16 @@ Widget html = Html( """, customRender: { - "table": (context, child) { + tableMatcher(): CustomRender.widget(widget: (context, child) { return SingleChildScrollView( scrollDirection: Axis.horizontal, child: (context.tree as TableLayoutElement).toWidget(context), ); - } + }), }, ); + +CustomRenderMatcher tableMatcher() => (context) => context.tree.element?.localName == "table" ?? false; ``` @@ -343,43 +349,52 @@ Widget html = Html( """, customRender: { - "iframe": (RenderContext context, Widget child) { - final attrs = context.tree.element?.attributes; - if (attrs != null) { - double? width = double.tryParse(attrs['width'] ?? ""); - double? height = double.tryParse(attrs['height'] ?? ""); - return Container( - width: width ?? (height ?? 150) * 2, - height: height ?? (width ?? 300) / 2, - child: WebView( - initialUrl: attrs['src'] ?? "about:blank", - javascriptMode: JavascriptMode.unrestricted, - //no need for scrolling gesture recognizers on embedded youtube, so set gestureRecognizers null - //on other iframe content scrolling might be necessary, so use VerticalDragGestureRecognizer - gestureRecognizers: attrs['src'] != null && attrs['src']!.contains("youtube.com/embed") ? null : [ - Factory(() => VerticalDragGestureRecognizer()) - ].toSet(), - navigationDelegate: (NavigationRequest request) async { - //no need to load any url besides the embedded youtube url when displaying embedded youtube, so prevent url loading - //on other iframe content allow all url loading - if (attrs['src'] != null && attrs['src']!.contains("youtube.com/embed")) { - if (!request.url.contains("youtube.com/embed")) { - return NavigationDecision.prevent; - } else { - return NavigationDecision.navigate; - } - } else { - return NavigationDecision.navigate; - } - }, - ), - ); - } else { - return Container(height: 0); - } - } - } + iframeYT(): CustomRender.widget(widget: (context, buildChildren) { + double? width = double.tryParse(context.tree.attributes['width'] ?? ""); + double? height = double.tryParse(context.tree.attributes['height'] ?? ""); + return Container( + width: width ?? (height ?? 150) * 2, + height: height ?? (width ?? 300) / 2, + child: WebView( + initialUrl: context.tree.attributes['src']!, + javascriptMode: JavascriptMode.unrestricted, + navigationDelegate: (NavigationRequest request) async { + //no need to load any url besides the embedded youtube url when displaying embedded youtube, so prevent url loading + if (!request.url.contains("youtube.com/embed")) { + return NavigationDecision.prevent; + } else { + return NavigationDecision.navigate; + } + }, + ), + ); + }), + iframeOther(): CustomRender.widget(widget: (context, buildChildren) { + double? width = double.tryParse(context.tree.attributes['width'] ?? ""); + double? height = double.tryParse(context.tree.attributes['height'] ?? ""); + return Container( + width: width ?? (height ?? 150) * 2, + height: height ?? (width ?? 300) / 2, + child: WebView( + initialUrl: context.tree.attributes['src'], + javascriptMode: JavascriptMode.unrestricted, + //on other iframe content scrolling might be necessary, so use VerticalDragGestureRecognizer + gestureRecognizers: [ + Factory(() => VerticalDragGestureRecognizer()) + ].toSet(), + ), + ); + }), + iframeNull(): CustomRender.widget(widget: (context, buildChildren) => Container(height: 0, width: 0)), + } ); + +CustomRenderMatcher iframeYT() => (context) => context.tree.element?.attributes['src']?.contains("youtube.com/embed") ?? false; + +CustomRenderMatcher iframeOther() => (context) => !(context.tree.element?.attributes['src']?.contains("youtube.com/embed") + ?? context.tree.element?.attributes['src'] == null); + +CustomRenderMatcher iframeNull() => (context) => context.tree.element?.attributes['src'] == null; ``` @@ -804,16 +819,23 @@ Then, use the `customRender` parameter to add the widget to render Tex. It could Widget htmlWidget = Html( data: r"""i\hbar\frac{\partial}{\partial t}\Psi(\vec x,t) = -\frac{\hbar}{2m}\nabla^2\Psi(\vec x,t)+ V(\vec x)\Psi(\vec x,t)""", customRender: { - "tex": (RenderContext context, _) => Math.tex( - context.tree.element!.text, + texMatcher(): CustomRender.widget(widget: (context, buildChildren) => Math.tex( + context.tree.element?.innerHtml ?? '', + mathStyle: MathStyle.display, + textStyle: context.style.generateTextStyle(), onErrorFallback: (FlutterMathException e) { - //return your error widget here e.g. - return Text(e.message); + if (context.parser.onMathError != null) { + return context.parser.onMathError!.call(context.tree.element?.innerHtml ?? '', e.message, e.messageWithType); + } else { + return Text(e.message); + } }, - ), + )), }, tagsList: Html.tags..add('tex'), ); + +CustomRenderMatcher texMatcher() => (context) => context.tree.element?.localName == 'tex'; ``` ### Table diff --git a/example/lib/main.dart b/example/lib/main.dart index 68a57c8d0c..74f48fef6e 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_html/flutter_html.dart'; +import 'package:flutter_math_fork/flutter_math.dart'; void main() => runApp(new MyApp()); @@ -250,7 +251,6 @@ class _MyHomePageState extends State { body: SingleChildScrollView( child: Html( data: htmlData, - tagsList: Html.tags..addAll(["bird", "flutter"]), style: { "table": Style( backgroundColor: Color.fromARGB(0x50, 0xee, 0xee, 0xee), @@ -268,26 +268,32 @@ class _MyHomePageState extends State { ), 'h5': Style(maxLines: 2, textOverflow: TextOverflow.ellipsis), }, - customRender: { - "table": (context, child) { - return SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: - (context.tree as TableLayoutElement).toWidget(context), - ); - }, - "bird": (RenderContext context, Widget child) { - return TextSpan(text: "🐦"); - }, - "flutter": (RenderContext context, Widget child) { - return FlutterLogo( - style: (context.tree.element!.attributes['horizontal'] != null) - ? FlutterLogoStyle.horizontal - : FlutterLogoStyle.markOnly, - textColor: context.style.color!, - size: context.style.fontSize!.size! * 5, - ); - }, + tagsList: Html.tags..addAll(["tex", "bird", "flutter"]), + customRenders: { + tagMatcher("tex"): CustomRender.widget(widget: (context, buildChildren) => Math.tex( + context.tree.element?.innerHtml ?? '', + mathStyle: MathStyle.display, + textStyle: context.style.generateTextStyle(), + onErrorFallback: (FlutterMathException e) { + if (context.parser.onMathError != null) { + return context.parser.onMathError!.call(context.tree.element?.innerHtml ?? '', e.message, e.messageWithType); + } else { + return Text(e.message); + } + }, + )), + tagMatcher("bird"): CustomRender.inlineSpan(inlineSpan: (context, buildChildren) => TextSpan(text: "🐦")), + tagMatcher("flutter"): CustomRender.widget(widget: (context, buildChildren) => FlutterLogo( + style: (context.tree.element!.attributes['horizontal'] != null) + ? FlutterLogoStyle.horizontal + : FlutterLogoStyle.markOnly, + textColor: context.style.color!, + size: context.style.fontSize!.size! * 5, + )), + tagMatcher("table"): CustomRender.widget(widget: (context, buildChildren) => SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: (context.tree as TableLayoutElement).toWidget(context), + )), }, customImageRenders: { networkSourceMatcher(domains: ["flutter.dev"]): diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 73ff399401..286a7a0b99 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -1,6 +1,6 @@ name: example description: flutter_html example app. - +publish_to: none version: 1.0.0+1 environment: diff --git a/lib/custom_render.dart b/lib/custom_render.dart new file mode 100644 index 0000000000..18911e8f7f --- /dev/null +++ b/lib/custom_render.dart @@ -0,0 +1,291 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_html/flutter_html.dart'; +import 'package:flutter_html/src/utils.dart'; + +typedef CustomRenderMatcher = bool Function(RenderContext context); + +CustomRenderMatcher tagMatcher(String tag) => (context) { + return context.tree.element?.localName == tag; +}; + +CustomRenderMatcher blockElementMatcher() => (context) { + return context.tree.style.display == Display.BLOCK && + (context.tree.children.isNotEmpty || context.tree.element?.localName == "hr"); + }; + +CustomRenderMatcher listElementMatcher() => (context) { + return context.tree.style.display == Display.LIST_ITEM; +}; + +CustomRenderMatcher replacedElementMatcher() => (context) { + return context.tree is ReplacedElement; +}; + +CustomRenderMatcher textContentElementMatcher() => (context) { + return context.tree is TextContentElement; +}; + +CustomRenderMatcher interactableElementMatcher() => (context) { + return context.tree is InteractableElement; +}; + +CustomRenderMatcher layoutElementMatcher() => (context) { + return context.tree is LayoutElement; +}; + +CustomRenderMatcher verticalAlignMatcher() => (context) { + return context.tree.style.verticalAlign != null + && context.tree.style.verticalAlign != VerticalAlign.BASELINE; +}; + +CustomRenderMatcher fallbackMatcher() => (context) { + return true; +}; + +class CustomRender { + final InlineSpan Function(RenderContext, List Function())? inlineSpan; + final Widget Function(RenderContext, List Function())? widget; + + CustomRender.inlineSpan({ + required this.inlineSpan, + }) : widget = null; + + CustomRender.widget({ + required this.widget, + }) : inlineSpan = null; +} + +class SelectableCustomRender extends CustomRender { + final TextSpan Function(RenderContext, List Function()) textSpan; + + SelectableCustomRender.fromTextSpan({ + required this.textSpan, + }) : super.inlineSpan(inlineSpan: null); +} + +CustomRender blockElementRender({ + Style? style, + Widget? child, + List? children,}) => + CustomRender.inlineSpan(inlineSpan: (context, buildChildren) { + if (context.parser.selectable) { + return TextSpan( + style: context.style.generateTextStyle(), + children: (children as List?) ?? context.tree.children + .expandIndexed((i, childTree) => [ + if (childTree.style.display == Display.BLOCK && + i > 0 && + context.tree.children[i - 1] is ReplacedElement) + TextSpan(text: "\n"), + context.parser.parseTree(context, childTree), + if (i != context.tree.children.length - 1 && + childTree.style.display == Display.BLOCK && + childTree.element?.localName != "html" && + childTree.element?.localName != "body") + TextSpan(text: "\n"), + ]) + .toList(), + ); + } + return WidgetSpan( + child: ContainerSpan( + key: context.key, + newContext: context, + style: style ?? context.tree.style, + shrinkWrap: context.parser.shrinkWrap, + children: children ?? context.tree.children + .expandIndexed((i, childTree) => [ + if (context.parser.shrinkWrap && + childTree.style.display == Display.BLOCK && + i > 0 && + context.tree.children[i - 1] is ReplacedElement) + TextSpan(text: "\n"), + context.parser.parseTree(context, childTree), + if (context.parser.shrinkWrap && + i != context.tree.children.length - 1 && + childTree.style.display == Display.BLOCK && + childTree.element?.localName != "html" && + childTree.element?.localName != "body") + TextSpan(text: "\n"), + ]) + .toList(), + )); + }); + +CustomRender listElementRender({ + Style? style, + Widget? child, + List? children}) => + CustomRender.inlineSpan(inlineSpan: (context, buildChildren) => + WidgetSpan( + child: ContainerSpan( + key: context.key, + newContext: context, + style: style ?? context.tree.style, + shrinkWrap: context.parser.shrinkWrap, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + textDirection: style?.direction ?? context.tree.style.direction, + children: [ + (style?.listStylePosition ?? context.tree.style.listStylePosition) == ListStylePosition.OUTSIDE ? + Padding( + padding: style?.padding?.nonNegative ?? context.tree.style.padding?.nonNegative + ?? EdgeInsets.only(left: (style?.direction ?? context.tree.style.direction) != TextDirection.rtl ? 10.0 : 0.0, + right: (style?.direction ?? context.tree.style.direction) == TextDirection.rtl ? 10.0 : 0.0), + child: style?.markerContent ?? context.style.markerContent + ) : Container(height: 0, width: 0), + Text("\t", textAlign: TextAlign.right, style: TextStyle(fontWeight: FontWeight.w400)), + Expanded( + child: Padding( + padding: (style?.listStylePosition ?? context.tree.style.listStylePosition) == ListStylePosition.INSIDE ? + EdgeInsets.only(left: (style?.direction ?? context.tree.style.direction) != TextDirection.rtl ? 10.0 : 0.0, + right: (style?.direction ?? context.tree.style.direction) == TextDirection.rtl ? 10.0 : 0.0) : EdgeInsets.zero, + child: StyledText( + textSpan: TextSpan( + children: _getListElementChildren(style?.listStylePosition ?? context.tree.style.listStylePosition, buildChildren) + ..insertAll(0, context.tree.style.listStylePosition == ListStylePosition.INSIDE ? + [ + WidgetSpan(alignment: PlaceholderAlignment.middle, child: style?.markerContent ?? context.style.markerContent ?? Container(height: 0, width: 0)) + ] : []), + style: style?.generateTextStyle() ?? context.style.generateTextStyle(), + ), + style: style ?? context.style, + renderContext: context, + ) + ) + ) + ], + ), + ), +)); + +CustomRender replacedElementRender({PlaceholderAlignment? alignment, TextBaseline? baseline, Widget? child}) => + CustomRender.inlineSpan(inlineSpan: (context, buildChildren) => WidgetSpan( + alignment: alignment ?? (context.tree as ReplacedElement).alignment, + baseline: baseline ?? TextBaseline.alphabetic, + child: child ?? (context.tree as ReplacedElement).toWidget(context)!, +)); + +CustomRender textContentElementRender({String? text}) => + CustomRender.inlineSpan(inlineSpan: (context, buildChildren) => + TextSpan(text: (text ?? (context.tree as TextContentElement).text).transformed(context.tree.style.textTransform))); + +CustomRender interactableElementRender({List? children}) => + CustomRender.inlineSpan(inlineSpan: (context, buildChildren) => TextSpan( + children: children ?? (context.tree as InteractableElement).children + .map((tree) => context.parser.parseTree(context, tree)) + .map((childSpan) { + return _getInteractableChildren(context, context.tree as InteractableElement, childSpan, + context.style.generateTextStyle().merge(childSpan.style)); + }).toList(), +)); + +CustomRender layoutElementRender({Widget? child}) => + CustomRender.inlineSpan(inlineSpan: (context, buildChildren) => WidgetSpan( + child: child ?? (context.tree as LayoutElement).toWidget(context)!, +)); + +CustomRender verticalAlignRender({ + double? verticalOffset, + Style? style, + List? children}) => + CustomRender.inlineSpan(inlineSpan: (context, buildChildren) => WidgetSpan( + child: Transform.translate( + key: context.key, + offset: Offset(0, verticalOffset ?? _getVerticalOffset(context.tree)), + child: StyledText( + textSpan: TextSpan( + style: style?.generateTextStyle() ?? context.style.generateTextStyle(), + children: children ?? buildChildren.call(), + ), + style: context.style, + renderContext: context, + ), + ), +)); + +CustomRender fallbackRender({Style? style, List? children}) => + CustomRender.inlineSpan(inlineSpan: (context, buildChildren) => TextSpan( + style: style?.generateTextStyle() ?? context.style.generateTextStyle(), + children: context.tree.children + .expand((tree) => [ + context.parser.parseTree(context, tree), + if (tree.style.display == Display.BLOCK && + tree.element?.localName != "html" && + tree.element?.localName != "body") + TextSpan(text: "\n"), + ]) + .toList(), +)); + +final Map defaultRenders = { + blockElementMatcher(): blockElementRender(), + listElementMatcher(): listElementRender(), + textContentElementMatcher(): textContentElementRender(), + replacedElementMatcher(): replacedElementRender(), + interactableElementMatcher(): interactableElementRender(), + layoutElementMatcher(): layoutElementRender(), + verticalAlignMatcher(): verticalAlignRender(), + fallbackMatcher(): fallbackRender(), +}; + +List _getListElementChildren(ListStylePosition? position, Function() buildChildren) { + List children = buildChildren.call(); + if (position == ListStylePosition.INSIDE) { + final tabSpan = WidgetSpan( + child: Text("\t", textAlign: TextAlign.right, style: TextStyle(fontWeight: FontWeight.w400)), + ); + children.insert(0, tabSpan); + } + return children; +} + +InlineSpan _getInteractableChildren(RenderContext context, InteractableElement tree, InlineSpan childSpan, TextStyle childStyle) { + if (childSpan is TextSpan) { + return TextSpan( + text: childSpan.text, + children: childSpan.children + ?.map((e) => _getInteractableChildren(context, tree, e, childStyle.merge(childSpan.style))) + .toList(), + style: context.style.generateTextStyle().merge( + childSpan.style == null + ? childStyle + : childStyle.merge(childSpan.style)), + semanticsLabel: childSpan.semanticsLabel, + recognizer: TapGestureRecognizer() + ..onTap = + context.parser.internalOnAnchorTap != null ? + () => context.parser.internalOnAnchorTap!(tree.href, context, tree.attributes, tree.element) + : null, + ); + } else { + return WidgetSpan( + child: MultipleTapGestureDetector( + onTap: context.parser.internalOnAnchorTap != null + ? () => context.parser.internalOnAnchorTap!(tree.href, context, tree.attributes, tree.element) + : null, + child: GestureDetector( + key: context.key, + onTap: context.parser.internalOnAnchorTap != null + ? () => context.parser.internalOnAnchorTap!(tree.href, context, tree.attributes, tree.element) + : null, + child: (childSpan as WidgetSpan).child, + ), + ), + ); + } +} + +double _getVerticalOffset(StyledElement tree) { + switch (tree.style.verticalAlign) { + case VerticalAlign.SUB: + return tree.style.fontSize!.size! / 2.5; + case VerticalAlign.SUPER: + return tree.style.fontSize!.size! / -2.5; + default: + return 0; + } +} \ No newline at end of file diff --git a/lib/flutter_html.dart b/lib/flutter_html.dart index eb714e8ccd..de2ef494e9 100644 --- a/lib/flutter_html.dart +++ b/lib/flutter_html.dart @@ -1,6 +1,7 @@ library flutter_html; import 'package:flutter/material.dart'; +import 'package:flutter_html/custom_render.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter_html/html_parser.dart'; import 'package:flutter_html/image_render.dart'; @@ -11,6 +12,12 @@ import 'package:flutter_html/src/navigation_delegate.dart'; //export render context api export 'package:flutter_html/html_parser.dart'; +//export render context api +export 'package:flutter_html/html_parser.dart'; +//export image render api +export 'package:flutter_html/image_render.dart'; +export 'package:flutter_html/custom_render.dart'; +//export image render api export 'package:flutter_html/image_render.dart'; //export src for advanced custom render uses (e.g. casting context.tree) export 'package:flutter_html/src/anchor.dart'; @@ -54,7 +61,7 @@ class Html extends StatelessWidget { required this.data, this.onLinkTap, this.onAnchorTap, - this.customRender = const {}, + this.customRenders = const {}, this.customImageRenders = const {}, this.onCssParseError, this.onImageError, @@ -75,7 +82,7 @@ class Html extends StatelessWidget { @required this.document, this.onLinkTap, this.onAnchorTap, - this.customRender = const {}, + this.customRenders = const {}, this.customImageRenders = const {}, this.onCssParseError, this.onImageError, @@ -132,7 +139,7 @@ class Html extends StatelessWidget { /// Either return a custom widget for specific node types or return null to /// fallback to the default rendering. - final Map customRender; + final Map customRenders; /// An API that allows you to override the default style for any HTML element final Map style; @@ -169,7 +176,9 @@ class Html extends StatelessWidget { shrinkWrap: shrinkWrap, selectable: false, style: style, - customRender: customRender, + customRenders: {} + ..addAll(customRenders) + ..addAll(defaultRenders), imageRenders: {} ..addAll(customImageRenders) ..addAll(defaultImageRenders), @@ -221,6 +230,7 @@ class SelectableHtml extends StatelessWidget { this.onCssParseError, this.shrinkWrap = false, this.style = const {}, + this.customRenders = const {}, this.tagsList = const [], this.selectionControls, this.scrollPhysics, @@ -238,6 +248,7 @@ class SelectableHtml extends StatelessWidget { this.onCssParseError, this.shrinkWrap = false, this.style = const {}, + this.customRenders = const {}, this.tagsList = const [], this.selectionControls, this.scrollPhysics, @@ -282,6 +293,10 @@ class SelectableHtml extends StatelessWidget { /// Allows you to override the default scrollPhysics for [SelectableText.rich] final ScrollPhysics? scrollPhysics; + /// Either return a custom widget for specific node types or return null to + /// fallback to the default rendering. + final Map customRenders; + static List get tags => new List.from(SELECTABLE_ELEMENTS); @override @@ -303,7 +318,9 @@ class SelectableHtml extends StatelessWidget { shrinkWrap: shrinkWrap, selectable: true, style: style, - customRender: {}, + customRenders: {} + ..addAll(customRenders) + ..addAll(defaultRenders), imageRenders: defaultImageRenders, tagsList: tagsList.isEmpty ? SelectableHtml.tags : tagsList, navigationDelegateForIframe: null, diff --git a/lib/html_parser.dart b/lib/html_parser.dart index 91aed52e35..60b773c860 100644 --- a/lib/html_parser.dart +++ b/lib/html_parser.dart @@ -1,10 +1,8 @@ import 'dart:collection'; import 'dart:math'; -import 'package:collection/collection.dart'; import 'package:csslib/parser.dart' as cssparser; import 'package:csslib/visitor.dart' as css; -import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter_html/flutter_html.dart'; @@ -35,10 +33,6 @@ typedef OnCssParseError = String? Function( String css, List errors, ); -typedef CustomRender = dynamic Function( - RenderContext context, - Widget parsedChild, -); class HtmlParser extends StatelessWidget { final Key? key; @@ -53,11 +47,11 @@ class HtmlParser extends StatelessWidget { final bool selectable; final Map style; - final Map customRender; + final Map customRenders; final Map imageRenders; final List tagsList; final NavigationDelegate? navigationDelegateForIframe; - final OnTap? _onAnchorTap; + final OnTap? internalOnAnchorTap; final TextSelectionControls? selectionControls; final ScrollPhysics? scrollPhysics; @@ -75,13 +69,13 @@ class HtmlParser extends StatelessWidget { required this.shrinkWrap, required this.selectable, required this.style, - required this.customRender, + required this.customRenders, required this.imageRenders, required this.tagsList, required this.navigationDelegateForIframe, this.selectionControls, this.scrollPhysics, - }) : this._onAnchorTap = onAnchorTap != null + }) : this.internalOnAnchorTap = onAnchorTap != null ? onAnchorTap : key != null ? _handleAnchorTap(key, onLinkTap) @@ -93,10 +87,11 @@ class HtmlParser extends StatelessWidget { Map>> declarations = _getExternalCssDeclarations(htmlData.getElementsByTagName("style"), onCssParseError); StyledElement lexedTree = lexDomTree( htmlData, - customRender.keys.toList(), + customRenders.keys.toList(), tagsList, navigationDelegateForIframe, context, + this, ); StyledElement? externalCssStyledTree; if (declarations.isNotEmpty) { @@ -161,10 +156,11 @@ class HtmlParser extends StatelessWidget { /// [lexDomTree] converts a DOM document to a simplified tree of [StyledElement]s. static StyledElement lexDomTree( dom.Document html, - List customRenderTags, + List customRenderMatchers, List tagsList, NavigationDelegate? navigationDelegateForIframe, BuildContext context, + HtmlParser parser, ) { StyledElement tree = StyledElement( name: "[Tree Root]", @@ -176,9 +172,11 @@ class HtmlParser extends StatelessWidget { html.nodes.forEach((node) { tree.children.add(_recursiveLexer( node, - customRenderTags, + customRenderMatchers, tagsList, navigationDelegateForIframe, + context, + parser, )); }); @@ -191,18 +189,22 @@ class HtmlParser extends StatelessWidget { /// element and returns a [StyledElement] tree representing the element. static StyledElement _recursiveLexer( dom.Node node, - List customRenderTags, + List customRenderMatchers, List tagsList, NavigationDelegate? navigationDelegateForIframe, + BuildContext context, + HtmlParser parser, ) { List children = []; node.nodes.forEach((childNode) { children.add(_recursiveLexer( childNode, - customRenderTags, + customRenderMatchers, tagsList, navigationDelegateForIframe, + context, + parser, )); }); @@ -223,9 +225,20 @@ class HtmlParser extends StatelessWidget { return parseTableCellElement(node, children); } else if (TABLE_DEFINITION_ELEMENTS.contains(node.localName)) { return parseTableDefinitionElement(node, children); - } else if (customRenderTags.contains(node.localName)) { - return parseStyledElement(node, children); } else { + final StyledElement tree = parseStyledElement(node, children); + for (final entry in customRenderMatchers) { + if (entry.call( + RenderContext( + buildContext: context, + parser: parser, + tree: tree, + style: Style.fromTextStyle(Theme.of(context).textTheme.bodyText2!), + ), + )) { + return tree; + } + } return EmptyContentElement(); } } else if (node is dom.Text) { @@ -316,7 +329,7 @@ class HtmlParser extends StatelessWidget { /// [parseTree] converts a tree of [StyledElement]s to an [InlineSpan] tree. /// - /// [parseTree] is responsible for handling the [customRender] parameter and + /// [parseTree] is responsible for handling the [customRenders] parameter and /// deciding what different `Style.display` options look like as Widgets. InlineSpan parseTree(RenderContext context, StyledElement tree) { // Merge this element's style into the context so that children @@ -326,237 +339,33 @@ class HtmlParser extends StatelessWidget { parser: this, tree: tree, style: context.style.copyOnlyInherited(tree.style), + key: AnchorKey.of(key, tree), ); - if (customRender.containsKey(tree.name)) { - final render = customRender[tree.name]!.call( - newContext, - ContainerSpan( - key: AnchorKey.of(key, tree), - newContext: newContext, - style: tree.style, - shrinkWrap: context.parser.shrinkWrap, - children: tree.children.map((tree) => parseTree(newContext, tree)).toList(), - ), - ); - if (render != null) { - assert(render is InlineSpan || render is Widget); - return render is InlineSpan - ? render - : WidgetSpan( - child: ContainerSpan( - key: AnchorKey.of(key, tree), - newContext: newContext, - style: tree.style, - shrinkWrap: context.parser.shrinkWrap, - child: render, - ), - ); - } - } - - //Return the correct InlineSpan based on the element type. - if (tree.style.display == Display.BLOCK && - (tree.children.isNotEmpty || tree.element?.localName == "hr")) { - if (newContext.parser.selectable) { - return TextSpan( - style: newContext.style.generateTextStyle(), - children: tree.children - .expandIndexed((i, childTree) => [ - if (childTree.style.display == Display.BLOCK && - i > 0 && - tree.children[i - 1] is ReplacedElement) - TextSpan(text: "\n"), - parseTree(newContext, childTree), - if (i != tree.children.length - 1 && - childTree.style.display == Display.BLOCK && - childTree.element?.localName != "html" && - childTree.element?.localName != "body") - TextSpan(text: "\n"), - ]) - .toList(), - ); - } - return WidgetSpan( - child: ContainerSpan( - key: AnchorKey.of(key, tree), - newContext: newContext, - style: tree.style, - shrinkWrap: context.parser.shrinkWrap, - children: tree.children - .expandIndexed((i, childTree) => [ - if (shrinkWrap && - childTree.style.display == Display.BLOCK && - i > 0 && - tree.children[i - 1] is ReplacedElement) - TextSpan(text: "\n"), - parseTree(newContext, childTree), - if (shrinkWrap && - i != tree.children.length - 1 && - childTree.style.display == Display.BLOCK && - childTree.element?.localName != "html" && - childTree.element?.localName != "body") - TextSpan(text: "\n"), - ]) - .toList(), - ), - ); - } else if (tree.style.display == Display.LIST_ITEM) { - List getChildren(StyledElement tree) { - List children = tree.children.map((tree) => parseTree(newContext, tree)).toList(); - if (tree.style.listStylePosition == ListStylePosition.INSIDE) { - final tabSpan = WidgetSpan( - child: Text("\t", textAlign: TextAlign.right, style: TextStyle(fontWeight: FontWeight.w400)), - ); - children.insert(0, tabSpan); + for (final entry in customRenders.keys) { + if (entry.call(newContext)) { + final buildChildren = () => tree.children.map((tree) => parseTree(newContext, tree)).toList(); + if (newContext.parser.selectable && customRenders[entry] is SelectableCustomRender) { + final selectableBuildChildren = () => tree.children.map((tree) => parseTree(newContext, tree) as TextSpan).toList(); + return (customRenders[entry] as SelectableCustomRender).textSpan.call(newContext, selectableBuildChildren); + } + if (newContext.parser.selectable) { + return customRenders[entry]!.inlineSpan!.call(newContext, buildChildren) as TextSpan; + } + if (customRenders[entry]?.inlineSpan != null) { + return customRenders[entry]!.inlineSpan!.call(newContext, buildChildren); } - return children; - } - - return WidgetSpan( - child: ContainerSpan( - key: AnchorKey.of(key, tree), - newContext: newContext, - style: tree.style, - shrinkWrap: context.parser.shrinkWrap, - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - textDirection: tree.style.direction, - children: [ - tree.style.listStylePosition == ListStylePosition.OUTSIDE ? - Padding( - padding: tree.style.padding?.nonNegative ?? EdgeInsets.only(left: tree.style.direction != TextDirection.rtl ? 10.0 : 0.0, right: tree.style.direction == TextDirection.rtl ? 10.0 : 0.0), - child: newContext.style.markerContent - ) : Container(height: 0, width: 0), - Text("\t", textAlign: TextAlign.right, style: TextStyle(fontWeight: FontWeight.w400)), - Expanded( - child: Padding( - padding: tree.style.listStylePosition == ListStylePosition.INSIDE ? - EdgeInsets.only(left: tree.style.direction != TextDirection.rtl ? 10.0 : 0.0, right: tree.style.direction == TextDirection.rtl ? 10.0 : 0.0) : EdgeInsets.zero, - child: StyledText( - textSpan: TextSpan( - children: getChildren(tree)..insertAll(0, tree.style.listStylePosition == ListStylePosition.INSIDE ? - [ - WidgetSpan(alignment: PlaceholderAlignment.middle, child: newContext.style.markerContent ?? Container(height: 0, width: 0)) - ] : []), - style: newContext.style.generateTextStyle(), - ), - style: newContext.style, - renderContext: context, - ) - ) - ) - ], - ), - ), - ); - } else if (tree is ReplacedElement) { - if (tree is TextContentElement) { - return TextSpan(text: tree.text?.transformed(tree.style.textTransform)); - } else { return WidgetSpan( - alignment: tree.alignment, - baseline: TextBaseline.alphabetic, - child: tree.toWidget(newContext)!, + child: ContainerSpan( + newContext: newContext, + style: tree.style, + shrinkWrap: newContext.parser.shrinkWrap, + child: customRenders[entry]!.widget!.call(newContext, buildChildren), + ), ); } - } else if (tree is InteractableElement) { - InlineSpan addTaps(InlineSpan childSpan, TextStyle childStyle) { - if (childSpan is TextSpan) { - return TextSpan( - mouseCursor: SystemMouseCursors.click, - text: childSpan.text, - children: childSpan.children - ?.map((e) => addTaps(e, childStyle.merge(childSpan.style))) - .toList(), - style: newContext.style.generateTextStyle().merge( - childSpan.style == null - ? childStyle - : childStyle.merge(childSpan.style)), - semanticsLabel: childSpan.semanticsLabel, - recognizer: TapGestureRecognizer() - ..onTap = - _onAnchorTap != null ? () => _onAnchorTap!(tree.href, context, tree.attributes, tree.element) : null, - ); - } else { - return WidgetSpan( - child: MouseRegion( - key: AnchorKey.of(key, tree), - cursor: SystemMouseCursors.click, - child: MultipleTapGestureDetector( - onTap: _onAnchorTap != null - ? () => _onAnchorTap!(tree.href, context, tree.attributes, tree.element) - : null, - child: GestureDetector( - key: AnchorKey.of(key, tree), - onTap: _onAnchorTap != null - ? () => _onAnchorTap!(tree.href, context, tree.attributes, tree.element) - : null, - child: (childSpan as WidgetSpan).child, - ), - ), - ), - ); - } - } - - return TextSpan( - mouseCursor: SystemMouseCursors.click, - children: tree.children - .map((tree) => parseTree(newContext, tree)) - .map((childSpan) { - return addTaps(childSpan, - newContext.style.generateTextStyle().merge(childSpan.style)); - }).toList(), - ); - } else if (tree is LayoutElement) { - return WidgetSpan( - child: tree.toWidget(context)!, - ); - } else if (tree.style.verticalAlign != null && - tree.style.verticalAlign != VerticalAlign.BASELINE) { - late double verticalOffset; - switch (tree.style.verticalAlign) { - case VerticalAlign.SUB: - verticalOffset = tree.style.fontSize!.size! / 2.5; - break; - case VerticalAlign.SUPER: - verticalOffset = tree.style.fontSize!.size! / -2.5; - break; - default: - break; - } - //Requires special layout features not available in the TextStyle API. - return WidgetSpan( - child: Transform.translate( - key: AnchorKey.of(key, tree), - offset: Offset(0, verticalOffset), - child: StyledText( - textSpan: TextSpan( - style: newContext.style.generateTextStyle(), - children: tree.children.map((tree) => parseTree(newContext, tree)).toList(), - ), - style: newContext.style, - renderContext: newContext, - ), - ), - ); - } else { - ///[tree] is an inline element. - return TextSpan( - style: newContext.style.generateTextStyle(), - children: tree.children - .expand((tree) => [ - parseTree(newContext, tree), - if (tree.style.display == Display.BLOCK && - tree.element?.localName != "html" && - tree.element?.localName != "body") - TextSpan(text: "\n"), - ]) - .toList(), - ); } + return WidgetSpan(child: Container(height: 0, width: 0)); } static OnTap _handleAnchorTap(Key key, OnTap? onLinkTap) => @@ -1017,12 +826,14 @@ class RenderContext { final HtmlParser parser; final StyledElement tree; final Style style; + final AnchorKey? key; RenderContext({ required this.buildContext, required this.parser, required this.tree, required this.style, + this.key, }); } diff --git a/lib/image_render.dart b/lib/image_render.dart index 9d576eb7c8..d9c812935f 100644 --- a/lib/image_render.dart +++ b/lib/image_render.dart @@ -4,7 +4,6 @@ import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter_html/html_parser.dart'; import 'package:flutter_svg/flutter_svg.dart'; -import 'package:flutter_svg/parser.dart'; import 'package:html/dom.dart' as dom; typedef ImageSourceMatcher = bool Function( diff --git a/test/html_parser_test.dart b/test/html_parser_test.dart index 8ca01768dd..fd438fbaf4 100644 --- a/test/html_parser_test.dart +++ b/test/html_parser_test.dart @@ -42,6 +42,26 @@ void testNewParser(BuildContext context) { [], null, context, + HtmlParser( + key: null, + htmlData: HtmlParser.parseHTML( + "Hello! Hello, World!Hello, New World!"), + onLinkTap: null, + onAnchorTap: null, + onImageTap: null, + onCssParseError: null, + onImageError: null, + onMathError: null, + shrinkWrap: false, + selectable: true, + style: {}, + customRenders: defaultRenders, + imageRenders: defaultImageRenders, + tagsList: Html.tags, + navigationDelegateForIframe: null, + selectionControls: null, + scrollPhysics: null, + ) ); print(tree.toString()); @@ -52,6 +72,26 @@ void testNewParser(BuildContext context) { [], null, context, + HtmlParser( + key: null, + htmlData: HtmlParser.parseHTML( + "Hello, World! This is a link"), + onLinkTap: null, + onAnchorTap: null, + onImageTap: null, + onCssParseError: null, + onImageError: null, + onMathError: null, + shrinkWrap: false, + selectable: true, + style: {}, + customRenders: defaultRenders, + imageRenders: defaultImageRenders, + tagsList: Html.tags, + navigationDelegateForIframe: null, + selectionControls: null, + scrollPhysics: null, + ) ); print(tree.toString()); @@ -61,6 +101,25 @@ void testNewParser(BuildContext context) { [], null, context, + HtmlParser( + key: null, + htmlData: HtmlParser.parseHTML(""), + onLinkTap: null, + onAnchorTap: null, + onImageTap: null, + onCssParseError: null, + onImageError: null, + onMathError: null, + shrinkWrap: false, + selectable: true, + style: {}, + customRenders: defaultRenders, + imageRenders: defaultImageRenders, + tagsList: Html.tags, + navigationDelegateForIframe: null, + selectionControls: null, + scrollPhysics: null, + ) ); print(tree.toString()); @@ -71,6 +130,26 @@ void testNewParser(BuildContext context) { [], null, context, + HtmlParser( + key: null, + htmlData: HtmlParser.parseHTML( + "
Link
Hello, World! Bold and Italic
"), + onLinkTap: null, + onAnchorTap: null, + onImageTap: null, + onCssParseError: null, + onImageError: null, + onMathError: null, + shrinkWrap: false, + selectable: true, + style: {}, + customRenders: defaultRenders, + imageRenders: defaultImageRenders, + tagsList: Html.tags, + navigationDelegateForIframe: null, + selectionControls: null, + scrollPhysics: null, + ) ); print(tree.toString());