diff --git a/packages/core/lib/flutter_widget_from_html_core.dart b/packages/core/lib/flutter_widget_from_html_core.dart index e59c0b52f..9c5ab20a7 100644 --- a/packages/core/lib/flutter_widget_from_html_core.dart +++ b/packages/core/lib/flutter_widget_from_html_core.dart @@ -1,3 +1,5 @@ +export 'package:html/dom.dart' show NodeList; + export 'src/core_data.dart'; export 'src/core_helpers.dart'; export 'src/core_html_widget.dart'; diff --git a/packages/core/lib/src/core_html_widget.dart b/packages/core/lib/src/core_html_widget.dart index ad1cb43bf..0424b59f3 100644 --- a/packages/core/lib/src/core_html_widget.dart +++ b/packages/core/lib/src/core_html_widget.dart @@ -40,6 +40,17 @@ class HtmlWidget extends StatefulWidget { /// The input string. final String html; + /// Pre-parsed DOM nodes, produced by [parseHtmlToNodes]. + /// + /// When provided, [html] is ignored entirely — the widget uses these nodes + /// directly and skips HTML-string-to-DOM parsing. You may pass an empty + /// string for [html]: + /// + /// ```dart + /// HtmlWidget('', parsedNodes: nodes) + /// ``` + final dom.NodeList? parsedNodes; + /// The custom [WidgetFactory] builder. final WidgetFactory Function()? factoryBuilder; @@ -66,6 +77,7 @@ class HtmlWidget extends StatefulWidget { /// - [buildAsync] /// - [enableCaching] /// - [html] + /// - [parsedNodes] /// - [renderMode] /// - [textStyle] List get rebuildTriggers => [ @@ -73,6 +85,7 @@ class HtmlWidget extends StatefulWidget { buildAsync, enableCaching, html, + parsedNodes, renderMode, textStyle, ..._rebuildTriggers ?? const [], @@ -106,6 +119,7 @@ class HtmlWidget extends StatefulWidget { this.onLoadingBuilder, this.onTapImage, this.onTapUrl, + this.parsedNodes, List? rebuildTriggers, this.renderMode = RenderMode.column, this.textStyle, @@ -124,8 +138,9 @@ class HtmlWidgetState extends State { Future? _future; InheritedProperties? _rootProperties; - bool get buildAsync => - widget.buildAsync ?? widget.html.length > kShouldBuildAsync; + bool get buildAsync => widget.parsedNodes != null + ? widget.buildAsync ?? false + : widget.buildAsync ?? widget.html.length > kShouldBuildAsync; bool get enableCaching => widget.enableCaching ?? !buildAsync; @@ -224,11 +239,12 @@ class HtmlWidgetState extends State { Future scrollToAnchor(String id) => _wf.onTapUrl('#$id'); Future _buildAsync() async { - if (widget.html.isEmpty) { + if (widget.html.isEmpty && widget.parsedNodes == null) { return Future.sync(() => _sliverOrWidget0); } - final domNodes = await compute(_parseHtml, widget.html); + final dom.NodeList domNodes = + widget.parsedNodes ?? await compute(parseHtmlToNodes, widget.html); if (!mounted) { return _sliverOrWidget0; } @@ -241,7 +257,7 @@ class HtmlWidgetState extends State { } Widget _buildSync() { - if (widget.html.isEmpty) { + if (widget.html.isEmpty && widget.parsedNodes == null) { return _sliverOrWidget0; } @@ -249,7 +265,7 @@ class HtmlWidgetState extends State { Widget built; try { - final domNodes = _parseHtml(widget.html); + final domNodes = widget.parsedNodes ?? parseHtmlToNodes(widget.html); built = _buildBody(this, domNodes); } catch (error, stackTrace) { built = @@ -325,5 +341,14 @@ Widget _buildBody(HtmlWidgetState state, dom.NodeList domNodes) { return built; } -dom.NodeList _parseHtml(String html) => +/// Parses an HTML string into a DOM node list. +/// +/// This is a pure-Dart function with no Flutter dependencies, so it can be +/// called inside a [compute] callback or a custom isolate: +/// +/// ```dart +/// final nodes = await Isolate.run(() => parseHtmlToNodes(myHtml)); +/// HtmlWidget(myHtml, parsedNodes: nodes); +/// ``` +dom.NodeList parseHtmlToNodes(String html) => parser.HtmlParser(html, parseMeta: false).parseFragment().nodes; diff --git a/packages/core/test/parsed_nodes_test.dart b/packages/core/test/parsed_nodes_test.dart new file mode 100644 index 000000000..9fede0ff0 --- /dev/null +++ b/packages/core/test/parsed_nodes_test.dart @@ -0,0 +1,94 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_widget_from_html_core/flutter_widget_from_html_core.dart'; +import 'package:html/dom.dart' as dom; + +import '_.dart'; + +void main() { + testWidgets('renders from parsedNodes', (tester) async { + const html = 'Hello World'; + final nodes = parseHtmlToNodes(html); + + final explained = await explain( + tester, + null, + hw: HtmlWidget( + '', + key: hwKey, + parsedNodes: nodes, + ), + ); + + expect(explained, equals('[RichText:(:Hello (+b:World))]')); + }); + + testWidgets('reacts to parsedNodes changes (new object)', (tester) async { + final nodes1 = parseHtmlToNodes('Foo'); + final nodes2 = parseHtmlToNodes('Bar'); + + final e1 = await explain( + tester, + null, + hw: HtmlWidget( + '', + key: hwKey, + parsedNodes: nodes1, + ), + ); + expect(e1, equals('[RichText:(:Foo)]')); + + final e2 = await explain( + tester, + null, + hw: HtmlWidget( + '', + key: hwKey, + parsedNodes: nodes2, + ), + ); + expect(e2, equals('[RichText:(:Bar)]')); + }); + + testWidgets('does NOT react to in-place parsedNodes changes', (tester) async { + final nodes = parseHtmlToNodes('Foo'); + + final e1 = await explain( + tester, + null, + hw: HtmlWidget( + '', + key: hwKey, + parsedNodes: nodes, + ), + ); + expect(e1, equals('[RichText:(:Foo)]')); + + // Modify nodes in place + nodes.first.text = 'Bar'; + + // We need to trigger a rebuild of the TestApp/HtmlWidget with the SAME nodes object + await tester.pumpWidget( + StatefulBuilder( + builder: (context, setState) => GestureDetector( + onTap: () => setState(() {}), + child: HtmlWidget( + '', + key: hwKey, + parsedNodes: nodes, + ), + ), + ), + ); + + // Initial state + expect(await explainWithoutPumping(), equals('[RichText:(:Foo)]')); + + // Trigger rebuild + await tester.tap(find.byType(GestureDetector)); + await tester.pump(); + + // It should STILL be Foo because rebuildTriggers didn't change (same NodeList object) + expect(await explainWithoutPumping(), equals('[RichText:(:Foo)]')); + }); +}