Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 28 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ A Flutter widget for rendering HTML and CSS as Flutter widgets.
- [API Reference](#api-reference)

- [Constructors](#constructors)

- [Selectable Text](#selectable-text)

- [Parameters Table](#parameters)

Expand Down Expand Up @@ -143,14 +145,34 @@ For a full example, see [here](https://github.com/Sub6Resources/flutter_html/tre

Below, you will find brief descriptions of the parameters the`Html` widget accepts and some code snippets to help you use this package.

## Constructors:
### Constructors:

The package currently has two different constructors - `Html()` and `Html.fromDom()`.

The `Html()` constructor is for those who would like to directly pass HTML from the source to the package to be rendered.

If you would like to modify or sanitize the HTML before rendering it, then `Html.fromDom()` is for you - you can convert the HTML string to a `Document` and use its methods to modify the HTML as you wish. Then, you can directly pass the modified `Document` to the package. This eliminates the need to parse the modified `Document` back to a string, pass to `Html()`, and convert back to a `Document`, thus cutting down on load times.

#### Selectable Text

The package also has two constructors for selectable text support - `SelectableHtml()` and `SelectableHtml.fromDom()`.

The difference between the two is the same as noted above.

Please note: Due to Flutter [#38474](https://github.com/flutter/flutter/issues/38474), selectable text support is significantly watered down compared to the standard non-selectable version of the widget. The changes are as follows:

1. No support for `customRender`, `customImageRender`, `onImageError`, `onImageTap`, `onMathError`, and `navigationDelegateForIframe`. (Support for `customRender` may be added in the future).

2. You cannot whitelist tags, you must use `blacklistedElements` to remove any tags that shouldn't be rendered. This is to make sure unsupported tags are not accidentally whitelisted, causing errors in the widget code.

3. The list of tags that can be rendered is significantly reduced. Key omissions include no support for images/video/audio, table, and ul/ol.

4. Styling support is significantly reduced. Only text-related styling works (e.g. bold or italic), while container related styling (e.g. borders or padding/margin) do not work.

5. Due to the above, the margins between elements no longer appear. As a result, the HTML content will not have proper spacing between elements like `<h1>`. The default margin for `<body>` is removed, so it is recommended to wrap the `Html()` widget in a `Container()` with padding to achieve the same effect.

Once the above issue is resolved, the aforementioned compromises will go away. Currently the `SelectableText.rich()` constructor does not support `WidgetSpan`s, resulting in the feature losses above.

### Parameters:

| Parameters | Description |
Expand All @@ -170,7 +192,9 @@ If you would like to modify or sanitize the HTML before rendering it, then `Html

### Getters:

Currently the only getter is `Html.tags`. This provides a list of all the tags the package renders. The main use case is to assist in blacklisting elements using `tagsList`. See an [example](#example-usage---tagslist---excluding-tags) below.
1. `Html.tags`. This provides a list of all the tags the package renders. The main use case is to assist in blacklisting elements using `tagsList`. See an [example](#example-usage---tagslist---excluding-tags) below.

2. `SelectableHtml.tags`. This provides a list of all the tags that can be rendered in selectable mode.

### Data:

Expand Down Expand Up @@ -304,6 +328,8 @@ Widget html = Html(
);
```

</details>

3. Complex example - rendering an `iframe` differently based on whether it is an embedded youtube video or some other embedded content.

<details><summary>View code</summary>
Expand Down
112 changes: 110 additions & 2 deletions lib/flutter_html.dart
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ class Html extends StatelessWidget {
this.navigationDelegateForIframe,
}) : data = null,
assert(document != null),
anchorKey = GlobalKey(),
anchorKey = GlobalKey(),
super(key: key);

/// A unique key for this Html widget to ensure uniqueness of anchors
Expand Down Expand Up @@ -111,7 +111,6 @@ class Html extends StatelessWidget {
/// You can return a widget here to override the default error widget.
final OnMathError? onMathError;


/// A parameter that should be set when the HTML widget is expected to be
/// flexible
final bool shrinkWrap;
Expand Down Expand Up @@ -157,6 +156,7 @@ class Html extends StatelessWidget {
onImageError: onImageError,
onMathError: onMathError,
shrinkWrap: shrinkWrap,
selectable: false,
style: style,
customRender: customRender,
imageRenders: {}
Expand All @@ -168,3 +168,111 @@ class Html extends StatelessWidget {
);
}
}

class SelectableHtml extends StatelessWidget {
/// The `SelectableHtml` widget takes HTML as input and displays a RichText
/// tree of the parsed HTML content (which is selectable)
///
/// **Attributes**
/// **data** *required* takes in a String of HTML data (required only for `Html` constructor).
/// **document** *required* takes in a Document of HTML data (required only for `Html.fromDom` constructor).
///
/// **onLinkTap** This function is called whenever a link (`<a href>`)
/// is tapped.
///
/// **blacklistedElements** Tag names in this array will not be rendered.
///
/// **style** Pass in the style information for the Html here.
/// See [its wiki page](https://github.com/Sub6Resources/flutter_html/wiki/Style) for more info.
///
/// **PLEASE NOTE**
///
/// There are a few caveats due to Flutter [#38474](https://github.com/flutter/flutter/issues/38474):
///
/// 1. No support for `customRender`, `customImageRender`, `onImageError`, `onImageTap`, `onMathError`, and `navigationDelegateForIframe`.
///
/// 2. You cannot whitelist tags, you must use `blacklistedElements` to remove any tags that shouldn't be rendered.
/// This is to make sure unsupported tags are not accidentally whitelisted, causing errors in the widget code.
///
/// 3. The list of tags that can be rendered is significantly reduced.
/// Key omissions include no support for images/video/audio, table, and ul/ol because they all require widgets and `WidgetSpan`s.
///
/// 4. Styling support is significantly reduced. Only text-related styling works
/// (e.g. bold or italic), while container related styling (e.g. borders or padding/margin)
/// do not work because we can't use the `ContainerSpan` class (it needs an enclosing `WidgetSpan`).
///
/// 5. Due to the above, the margins between elements no longer appear.
/// As a result, the HTML content will not have proper spacing between elements like `h1`. The default margin for `body` is removed as well.
SelectableHtml({
Key? key,
required this.data,
this.onLinkTap,
this.onCssParseError,
this.shrinkWrap = false,
this.style = const {},
this.blacklistedElements = const [],
}) : document = null,
super(key: key);

SelectableHtml.fromDom({
Key? key,
required this.document,
this.onLinkTap,
this.onCssParseError,
this.shrinkWrap = false,
this.style = const {},
this.blacklistedElements = const [],
}) : data = null,
super(key: key);

/// The HTML data passed to the widget as a String
final String? data;

/// The HTML data passed to the widget as a pre-processed [dom.Document]
final dom.Document? document;

/// A function that defines what to do when a link is tapped
final OnTap? onLinkTap;

/// A function that defines what to do when CSS fails to parse
final OnCssParseError? onCssParseError;

/// A parameter that should be set when the HTML widget is expected to be
/// flexible
final bool shrinkWrap;

/// A list of HTML tags that defines what elements are not rendered
final List<String> blacklistedElements;

/// An API that allows you to override the default style for any HTML element
final Map<String, Style> style;

static List<String> get tags => new List<String>.from(SELECTABLE_ELEMENTS);

@override
Widget build(BuildContext context) {
final dom.Document doc = data != null ? HtmlParser.parseHTML(data!) : document!;
final double? width = shrinkWrap ? null : MediaQuery.of(context).size.width;

return Container(
width: width,
child: HtmlParser(
key: null,
htmlData: doc,
onLinkTap: onLinkTap,
onImageTap: null,
onCssParseError: onCssParseError,
onImageError: null,
onMathError: null,
shrinkWrap: shrinkWrap,
selectable: true,
style: style,
customRender: {},
imageRenders: {}
..addAll(defaultImageRenders),
tagsList: SelectableHtml.tags..removeWhere((element) => (blacklistedElements).contains(element)),
navigationDelegateForIframe: null,
),
);
}
}
78 changes: 76 additions & 2 deletions lib/html_parser.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
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';
Expand Down Expand Up @@ -46,6 +47,7 @@ class HtmlParser extends StatelessWidget {
final ImageErrorListener? onImageError;
final OnMathError? onMathError;
final bool shrinkWrap;
final bool selectable;

final Map<String, Style> style;
final Map<String, CustomRender> customRender;
Expand All @@ -63,6 +65,7 @@ class HtmlParser extends StatelessWidget {
required this.onImageError,
required this.onMathError,
required this.shrinkWrap,
required this.selectable,
required this.style,
required this.customRender,
required this.imageRenders,
Expand Down Expand Up @@ -101,6 +104,19 @@ class HtmlParser extends StatelessWidget {
// using textScaleFactor = 1.0 (which is the default). This ensures the correct
// scaling is used, but relies on https://github.com/flutter/flutter/pull/59711
// to wrap everything when larger accessibility fonts are used.
if (selectable) {
return StyledText.selectable(
textSpan: parsedTree as TextSpan,
style: cleanedTree.style,
textScaleFactor: MediaQuery.of(context).textScaleFactor,
renderContext: RenderContext(
buildContext: context,
parser: this,
tree: cleanedTree,
style: Style.fromTextStyle(Theme.of(context).textTheme.bodyText2!),
),
);
}
return StyledText(
textSpan: parsedTree,
style: cleanedTree.style,
Expand Down Expand Up @@ -288,7 +304,28 @@ class HtmlParser extends StatelessWidget {
tree: tree,
style: context.style.copyOnlyInherited(tree.style),
);

List<int> indices = [];
tree.children.forEachIndexed((index, element) {
//we want the element to be a block element, but we don't want to add
//new-lines before/after the html and body
if (element.style.display == Display.BLOCK
&& element.element?.localName != "html"
&& element.element?.localName != "body"
) {
//if the parent element is body and the element is first, we don't want
//to add a new-line before
if (index == 0 && element.element?.parent?.localName == "body") {
indices.add(index + 1);
} else {
indices.addAll([index, index + 1]);
}
}
});
//we don't need a new-line at the end
if (indices.isNotEmpty && indices.last == tree.children.length) {
indices.removeLast();
}
indices = indices.toSet().toList();
if (customRender.containsKey(tree.name)) {
final render = customRender[tree.name]!.call(
newContext,
Expand Down Expand Up @@ -318,6 +355,18 @@ class HtmlParser extends StatelessWidget {

//Return the correct InlineSpan based on the element type.
if (tree.style.display == Display.BLOCK) {
if (newContext.parser.selectable) {
final children = tree.children.map((tree) => parseTree(newContext, tree)).toList();
//use provided indices to insert new-lines at those locations
//makes sure to account for list size changes with "+ i"
indices.forEachIndexed((i, element) {
children.insert(element + i, TextSpan(text: "\n"));
});
return TextSpan(
style: newContext.style.generateTextStyle(),
children: children,
);
}
return WidgetSpan(
child: ContainerSpan(
key: AnchorKey.of(key, tree),
Expand Down Expand Up @@ -854,17 +903,42 @@ class StyledText extends StatelessWidget {
final double textScaleFactor;
final RenderContext renderContext;
final AnchorKey? key;
final bool _selectable;

const StyledText({
required this.textSpan,
required this.style,
this.textScaleFactor = 1.0,
required this.renderContext,
this.key,
}) : super(key: key);
}) : _selectable = false,
super(key: key);

const StyledText.selectable({
required TextSpan textSpan,
required this.style,
this.textScaleFactor = 1.0,
required this.renderContext,
this.key,
}) : textSpan = textSpan,
_selectable = true,
super(key: key);

@override
Widget build(BuildContext context) {
if (_selectable) {
return SizedBox(
width: calculateWidth(style.display, renderContext),
child: SelectableText.rich(
textSpan as TextSpan,
style: style.generateTextStyle(),
textAlign: style.textAlign,
textDirection: style.direction,
textScaleFactor: textScaleFactor,
maxLines: style.maxLines,
),
);
}
return SizedBox(
width: calculateWidth(style.display, renderContext),
child: Text.rich(
Expand Down
Loading