mohsen zamani
2 years ago
53 changed files with 11558 additions and 114 deletions
-
11android/app/build.gradle
-
24android/app/src/main/AndroidManifest.xml
-
4android/build.gradle
-
4007assets/lottie/loading.json
-
18lib/core/extensions/number_extension.dart
-
489lib/core/html/custom_render.dart
-
326lib/core/html/flutter_html.dart
-
877lib/core/html/html_parser.dart
-
251lib/core/html/html_viewer.dart
-
45lib/core/html/src/anchor.dart
-
734lib/core/html/src/css_box_widget.dart
-
1172lib/core/html/src/css_parser.dart
-
207lib/core/html/src/html_elements.dart
-
64lib/core/html/src/interactable_element.dart
-
209lib/core/html/src/layout_element.dart
-
167lib/core/html/src/replaced_element.dart
-
30lib/core/html/src/style/fontsize.dart
-
64lib/core/html/src/style/length.dart
-
24lib/core/html/src/style/lineheight.dart
-
73lib/core/html/src/style/margin.dart
-
35lib/core/html/src/style/marker.dart
-
15lib/core/html/src/style/size.dart
-
411lib/core/html/src/styled_element.dart
-
89lib/core/html/src/utils.dart
-
220lib/core/html/string_proccess.dart
-
551lib/core/html/style.dart
-
11lib/core/language/language_cubit.dart
-
9lib/core/language/languages.dart
-
1lib/core/language/translator.dart
-
74lib/core/player_widgets/audio_player.dart
-
67lib/core/player_widgets/video_player.dart
-
144lib/core/theme/app_colors.dart
-
130lib/core/theme/app_theme.dart
-
4lib/core/theme/cubit/theme_cubit.dart
-
8lib/core/theme/panel_colors.dart
-
4lib/core/theme/panel_theme.dart
-
6lib/core/theme/panel_typography.dart
-
37lib/core/theme/reader_theme.dart
-
23lib/core/utils/app_utils.dart
-
86lib/core/utils/url_launcher.dart
-
45lib/core/utils/utilities.dart
-
52lib/core/widgets/custom_rich_text.dart
-
54lib/core/widgets/global_loading.dart
-
63lib/core/widgets/show_image_widget.dart
-
17lib/features/main/main_screen.dart
-
2lib/features/main/widget/main_item_widget.dart
-
121lib/features/posts/screen/posts_screen.dart
-
2lib/features/posts/widgets/filter_item_widget.dart
-
153lib/features/single_post/screen/single_post_screen.dart
-
120lib/features/single_post/view_models/post.dart
-
31lib/features/single_post/view_models/thumbnail.dart
-
278pubspec.lock
-
13pubspec.yaml
4007
assets/lottie/loading.json
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
View File
@ -0,0 +1,18 @@ |
|||
import 'package:sonnat/core/utils/app_constants.dart'; |
|||
|
|||
extension NumberExtension on num { |
|||
double get sw { |
|||
if (this == 1) { |
|||
return double.maxFinite; |
|||
} |
|||
return toDouble() * AppConstants.instance.appWidth; |
|||
} |
|||
|
|||
double get sh => toDouble() * AppConstants.instance.appHeight; |
|||
|
|||
double get w => toDouble(); |
|||
|
|||
double get h => toDouble(); |
|||
|
|||
double get sp => toDouble(); |
|||
} |
@ -0,0 +1,489 @@ |
|||
import 'dart:async'; |
|||
import 'dart:convert'; |
|||
|
|||
import 'package:collection/collection.dart'; |
|||
import 'package:flutter/gestures.dart'; |
|||
import 'package:flutter/material.dart'; |
|||
import 'package:sonnat/core/html/html_parser.dart'; |
|||
import 'package:sonnat/core/html/src/css_box_widget.dart'; |
|||
import 'package:sonnat/core/html/src/html_elements.dart'; |
|||
import 'package:sonnat/core/html/src/layout_element.dart'; |
|||
import 'package:sonnat/core/html/src/utils.dart'; |
|||
import 'package:sonnat/core/html/style.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.style.display == Display.inlineBlock) && |
|||
(context.tree.children.isNotEmpty || context.tree.element?.localName == 'hr'); |
|||
}; |
|||
|
|||
CustomRenderMatcher listElementMatcher() => (context) { |
|||
return context.tree.style.display == Display.listItem; |
|||
}; |
|||
|
|||
CustomRenderMatcher replacedElementMatcher() => (context) { |
|||
return context.tree is ReplacedElement; |
|||
}; |
|||
|
|||
CustomRenderMatcher dataUriMatcher({String? encoding = 'base64', String? mime}) => (context) { |
|||
if (context.tree.element?.attributes == null || _src(context.tree.element!.attributes.cast()) == null) { |
|||
return false; |
|||
} |
|||
final dataUri = _dataUriFormat.firstMatch(_src(context.tree.element!.attributes.cast())!); |
|||
return dataUri != null && |
|||
dataUri.namedGroup('mime') != 'image/svg+xml' && |
|||
(mime == null || dataUri.namedGroup('mime') == mime) && |
|||
(encoding == null || dataUri.namedGroup('encoding') == ';$encoding'); |
|||
}; |
|||
|
|||
CustomRenderMatcher networkSourceMatcher({ |
|||
List<String> schemas = const ['https', 'http'], |
|||
List<String>? domains, |
|||
String? extension, |
|||
}) => |
|||
(context) { |
|||
if (context.tree.element?.attributes.cast() == null || _src(context.tree.element!.attributes.cast()) == null) { |
|||
return false; |
|||
} |
|||
try { |
|||
final src = Uri.parse(_src(context.tree.element!.attributes.cast())!); |
|||
return schemas.contains(src.scheme) && |
|||
(domains == null || domains.contains(src.host)) && |
|||
(extension == null || src.path.endsWith('.$extension')); |
|||
} catch (e) { |
|||
return false; |
|||
} |
|||
}; |
|||
|
|||
CustomRenderMatcher assetUriMatcher() => (context) => |
|||
context.tree.element?.attributes.cast() != null && |
|||
_src(context.tree.element!.attributes.cast()) != null && |
|||
_src(context.tree.element!.attributes.cast())!.startsWith('asset:') && |
|||
!_src(context.tree.element!.attributes.cast())!.endsWith('.svg'); |
|||
|
|||
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<InlineSpan> Function())? inlineSpan; |
|||
final Widget Function(RenderContext, List<InlineSpan> 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<TextSpan> Function()) textSpan; |
|||
|
|||
SelectableCustomRender.fromTextSpan({ |
|||
required this.textSpan, |
|||
}) : super.inlineSpan(inlineSpan: null); |
|||
} |
|||
|
|||
CustomRender blockElementRender({Style? style, List<InlineSpan>? children}) => |
|||
CustomRender.inlineSpan(inlineSpan: (context, buildChildren) { |
|||
if (context.parser.selectable) { |
|||
return TextSpan( |
|||
style: context.style.generateTextStyle(), |
|||
children: (children as List<TextSpan>?) ?? |
|||
context.tree.children |
|||
.expandIndexed((i, childTree) => [ |
|||
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') |
|||
const TextSpan(text: '\n'), |
|||
]) |
|||
.toList(), |
|||
); |
|||
} |
|||
return WidgetSpan( |
|||
alignment: PlaceholderAlignment.baseline, |
|||
baseline: TextBaseline.alphabetic, |
|||
child: CssBoxWidget.withInlineSpanChildren( |
|||
key: context.key, |
|||
style: style ?? context.tree.style, |
|||
shrinkWrap: context.parser.shrinkWrap, |
|||
childIsReplaced: HtmlElements.replacedExternalElements.contains(context.tree.name), |
|||
children: children ?? |
|||
context.tree.children |
|||
.expandIndexed((i, childTree) => [ |
|||
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') |
|||
const TextSpan(text: '\n'), |
|||
]) |
|||
.toList(), |
|||
), |
|||
); |
|||
}); |
|||
|
|||
CustomRender listElementRender({Style? style, Widget? child, List<InlineSpan>? children}) { |
|||
return CustomRender.inlineSpan( |
|||
inlineSpan: (context, buildChildren) { |
|||
return WidgetSpan( |
|||
child: CssBoxWidget.withInlineSpanChildren( |
|||
key: context.key, |
|||
style: style ?? context.style, |
|||
shrinkWrap: context.parser.shrinkWrap, |
|||
children: buildChildren(), |
|||
), |
|||
); |
|||
}, |
|||
); |
|||
} |
|||
|
|||
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( |
|||
style: context.style.generateTextStyle(), |
|||
text: (text ?? (context.tree as TextContentElement).text)?.transformed(context.tree.style.textTransform), |
|||
), |
|||
); |
|||
|
|||
CustomRender base64ImageRender() => CustomRender.widget(widget: (context, buildChildren) { |
|||
final decodedImage = base64.decode(_src(context.tree.element!.attributes.cast())!.split('base64,')[1].trim()); |
|||
precacheImage( |
|||
MemoryImage(decodedImage), |
|||
context.buildContext, |
|||
onError: (exception, stackTrace) { |
|||
context.parser.onImageError?.call(exception, stackTrace); |
|||
}, |
|||
); |
|||
final widget = Image.memory( |
|||
decodedImage, |
|||
frameBuilder: (ctx, child, frame, _) { |
|||
if (frame == null) { |
|||
return Text(_alt(context.tree.element!.attributes.cast()) ?? '', style: context.style.generateTextStyle()); |
|||
} |
|||
return child; |
|||
}, |
|||
); |
|||
return Builder( |
|||
key: context.key, |
|||
builder: (buildContext) { |
|||
return GestureDetector( |
|||
child: widget, |
|||
onTap: () { |
|||
if (MultipleTapGestureDetector.of(buildContext) != null) { |
|||
MultipleTapGestureDetector.of(buildContext)!.onTap?.call(); |
|||
} |
|||
context.parser.onImageTap?.call( |
|||
_src(context.tree.element!.attributes.cast())!.split('base64,')[1].trim(), |
|||
context, |
|||
context.tree.element!.attributes.cast(), |
|||
context.tree.element); |
|||
}, |
|||
); |
|||
}); |
|||
}); |
|||
|
|||
CustomRender assetImageRender({ |
|||
double? width, |
|||
double? height, |
|||
}) => |
|||
CustomRender.widget(widget: (context, buildChildren) { |
|||
final assetPath = _src(context.tree.element!.attributes.cast())!.replaceFirst('asset:', ''); |
|||
final widget = Image.asset( |
|||
assetPath, |
|||
width: width ?? _width(context.tree.element!.attributes.cast()), |
|||
height: height ?? _height(context.tree.element!.attributes.cast()), |
|||
frameBuilder: (ctx, child, frame, _) { |
|||
if (frame == null) { |
|||
return Text(_alt(context.tree.element!.attributes.cast()) ?? '', style: context.style.generateTextStyle()); |
|||
} |
|||
return child; |
|||
}, |
|||
); |
|||
return Builder( |
|||
key: context.key, |
|||
builder: (buildContext) { |
|||
return GestureDetector( |
|||
child: widget, |
|||
onTap: () { |
|||
if (MultipleTapGestureDetector.of(buildContext) != null) { |
|||
MultipleTapGestureDetector.of(buildContext)!.onTap?.call(); |
|||
} |
|||
context.parser.onImageTap |
|||
?.call(assetPath, context, context.tree.element!.attributes.cast(), context.tree.element); |
|||
}, |
|||
); |
|||
}); |
|||
}); |
|||
|
|||
CustomRender networkImageRender({ |
|||
Map<String, String>? headers, |
|||
String Function(String?)? mapUrl, |
|||
double? width, |
|||
double? height, |
|||
Widget Function(String?)? altWidget, |
|||
Widget Function()? loadingWidget, |
|||
}) => |
|||
CustomRender.widget(widget: (context, buildChildren) { |
|||
final src = |
|||
mapUrl?.call(_src(context.tree.element!.attributes.cast())) ?? _src(context.tree.element!.attributes.cast())!; |
|||
Completer<Size> completer = Completer(); |
|||
if (context.parser.cachedImageSizes[src] != null) { |
|||
completer.complete(context.parser.cachedImageSizes[src]); |
|||
} else { |
|||
Image image = Image.network(src, frameBuilder: (ctx, child, frame, _) { |
|||
if (frame == null) { |
|||
if (!completer.isCompleted) { |
|||
completer.completeError('error'); |
|||
} |
|||
return child; |
|||
} else { |
|||
return child; |
|||
} |
|||
}); |
|||
|
|||
ImageStreamListener? listener; |
|||
listener = ImageStreamListener((imageInfo, synchronousCall) { |
|||
var myImage = imageInfo.image; |
|||
Size size = Size(myImage.width.toDouble(), myImage.height.toDouble()); |
|||
if (!completer.isCompleted) { |
|||
context.parser.cachedImageSizes[src] = size; |
|||
completer.complete(size); |
|||
image.image.resolve(const ImageConfiguration()).removeListener(listener!); |
|||
} |
|||
}, onError: (object, stacktrace) { |
|||
if (!completer.isCompleted) { |
|||
completer.completeError(object); |
|||
image.image.resolve(const ImageConfiguration()).removeListener(listener!); |
|||
} |
|||
}); |
|||
|
|||
image.image.resolve(const ImageConfiguration()).addListener(listener); |
|||
} |
|||
final attributes = context.tree.element!.attributes.cast<String, String>(); |
|||
final widget = FutureBuilder<Size>( |
|||
future: completer.future, |
|||
initialData: context.parser.cachedImageSizes[src], |
|||
builder: (buildContext, snapshot) { |
|||
if (snapshot.hasData) { |
|||
return Container( |
|||
constraints: BoxConstraints( |
|||
maxWidth: width ?? _width(attributes) ?? snapshot.data!.width, |
|||
maxHeight: |
|||
(width ?? _width(attributes) ?? snapshot.data!.width) / _aspectRatio(attributes, snapshot)), |
|||
child: AspectRatio( |
|||
aspectRatio: _aspectRatio(attributes, snapshot), |
|||
child: Image.network( |
|||
src, |
|||
headers: headers, |
|||
width: width ?? _width(attributes) ?? snapshot.data!.width, |
|||
height: height ?? _height(attributes), |
|||
frameBuilder: (ctx, child, frame, _) { |
|||
if (frame == null) { |
|||
return altWidget?.call(_alt(attributes)) ?? |
|||
Text(_alt(attributes) ?? '', style: context.style.generateTextStyle()); |
|||
} |
|||
return child; |
|||
}, |
|||
), |
|||
), |
|||
); |
|||
} else if (snapshot.hasError) { |
|||
return altWidget?.call(_alt(context.tree.element!.attributes.cast())) ?? |
|||
Text(_alt(context.tree.element!.attributes.cast()) ?? '', style: context.style.generateTextStyle()); |
|||
} else { |
|||
return loadingWidget?.call() ?? const CircularProgressIndicator(); |
|||
} |
|||
}, |
|||
); |
|||
return Builder( |
|||
key: context.key, |
|||
builder: (buildContext) { |
|||
return GestureDetector( |
|||
child: widget, |
|||
onTap: () { |
|||
if (MultipleTapGestureDetector.of(buildContext) != null) { |
|||
MultipleTapGestureDetector.of(buildContext)!.onTap?.call(); |
|||
} |
|||
context.parser.onImageTap |
|||
?.call(src, context, context.tree.element!.attributes.cast(), context.tree.element); |
|||
}, |
|||
); |
|||
}); |
|||
}); |
|||
|
|||
CustomRender interactableElementRender({List<InlineSpan>? 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<InlineSpan>? children}) => |
|||
CustomRender.inlineSpan( |
|||
inlineSpan: (context, buildChildren) => WidgetSpan( |
|||
child: Transform.translate( |
|||
key: context.key, |
|||
offset: Offset(0, verticalOffset ?? _getVerticalOffset(context.tree)), |
|||
child: CssBoxWidget.withInlineSpanChildren( |
|||
children: children ?? buildChildren.call(), |
|||
style: context.style, |
|||
), |
|||
), |
|||
)); |
|||
|
|||
CustomRender fallbackRender({Style? style, List<InlineSpan>? 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?.parent?.localName != 'th' && |
|||
tree.element?.parent?.localName != 'td' && |
|||
tree.element?.localName != 'html' && |
|||
tree.element?.localName != 'body') |
|||
const TextSpan(text: '\n'), |
|||
]) |
|||
.toList(), |
|||
)); |
|||
|
|||
Map<CustomRenderMatcher, CustomRender> generateDefaultRenders() { |
|||
return { |
|||
blockElementMatcher(): blockElementRender(), |
|||
listElementMatcher(): listElementRender(), |
|||
textContentElementMatcher(): textContentElementRender(), |
|||
dataUriMatcher(): base64ImageRender(), |
|||
assetUriMatcher(): assetImageRender(), |
|||
networkSourceMatcher(): networkImageRender(), |
|||
replacedElementMatcher(): replacedElementRender(), |
|||
interactableElementMatcher(): interactableElementRender(), |
|||
layoutElementMatcher(): layoutElementRender(), |
|||
verticalAlignMatcher(): verticalAlignRender(), |
|||
fallbackMatcher(): fallbackRender(), |
|||
}; |
|||
} |
|||
|
|||
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, |
|||
), |
|||
), |
|||
); |
|||
} |
|||
} |
|||
|
|||
final _dataUriFormat = RegExp('^(?<scheme>data):(?<mime>image\\/[\\w\\+\\-\\.]+)(?<encoding>;base64)?\\,(?<data>.*)'); |
|||
|
|||
double _getVerticalOffset(StyledElement tree) { |
|||
switch (tree.style.verticalAlign) { |
|||
case VerticalAlign.sub: |
|||
return tree.style.fontSize!.value / 2.5; |
|||
case VerticalAlign.sup: |
|||
return tree.style.fontSize!.value / -2.5; |
|||
default: |
|||
return 0; |
|||
} |
|||
} |
|||
|
|||
String? _src(Map<String, String> attributes) { |
|||
return attributes['src']; |
|||
} |
|||
|
|||
String? _alt(Map<String, String> attributes) { |
|||
return attributes['alt']; |
|||
} |
|||
|
|||
double? _height(Map<String, String> attributes) { |
|||
final heightString = attributes['height']; |
|||
return heightString == null ? heightString as double? : double.tryParse(heightString); |
|||
} |
|||
|
|||
double? _width(Map<String, String> attributes) { |
|||
final widthString = attributes['width']; |
|||
return widthString == null ? widthString as double? : double.tryParse(widthString); |
|||
} |
|||
|
|||
double _aspectRatio(Map<String, String> attributes, AsyncSnapshot<Size> calculated) { |
|||
final heightString = attributes['height']; |
|||
final widthString = attributes['width']; |
|||
if (heightString != null && widthString != null) { |
|||
final height = double.tryParse(heightString); |
|||
final width = double.tryParse(widthString); |
|||
return height == null || width == null ? calculated.data!.aspectRatio : width / height; |
|||
} |
|||
return calculated.data!.aspectRatio; |
|||
} |
|||
|
|||
extension ClampedEdgeInsets on EdgeInsetsGeometry { |
|||
EdgeInsetsGeometry get nonNegative => clamp(EdgeInsets.zero, const EdgeInsets.all(double.infinity)); |
|||
} |
@ -0,0 +1,326 @@ |
|||
library flutter_html; |
|||
|
|||
import 'package:flutter/material.dart'; |
|||
import 'package:html/dom.dart' as dom; |
|||
import 'package:sonnat/core/html/custom_render.dart'; |
|||
import 'package:sonnat/core/html/html_parser.dart'; |
|||
import 'package:sonnat/core/html/src/html_elements.dart'; |
|||
import 'package:sonnat/core/html/style.dart'; |
|||
|
|||
class Html extends StatefulWidget { |
|||
Html({ |
|||
super.key, |
|||
GlobalKey? anchorKey, |
|||
required this.data, |
|||
this.onLinkTap, |
|||
this.onAnchorTap, |
|||
this.customRenders = const {}, |
|||
this.onCssParseError, |
|||
this.onImageError, |
|||
this.shrinkWrap = false, |
|||
this.onImageTap, |
|||
this.tagsList = const [], |
|||
this.style = const {}, |
|||
}) : documentElement = null, |
|||
assert(data != null), |
|||
_anchorKey = anchorKey ?? GlobalKey(); |
|||
|
|||
Html.fromDom({ |
|||
super.key, |
|||
GlobalKey? anchorKey, |
|||
@required dom.Document? document, |
|||
this.onLinkTap, |
|||
this.onAnchorTap, |
|||
this.customRenders = const {}, |
|||
this.onCssParseError, |
|||
this.onImageError, |
|||
this.shrinkWrap = false, |
|||
this.onImageTap, |
|||
this.tagsList = const [], |
|||
this.style = const {}, |
|||
}) : data = null, |
|||
assert(document != null), |
|||
documentElement = document!.documentElement, |
|||
_anchorKey = anchorKey ?? GlobalKey(); |
|||
|
|||
Html.fromElement({ |
|||
super.key, |
|||
GlobalKey? anchorKey, |
|||
@required this.documentElement, |
|||
this.onLinkTap, |
|||
this.onAnchorTap, |
|||
this.customRenders = const {}, |
|||
this.onCssParseError, |
|||
this.onImageError, |
|||
this.shrinkWrap = false, |
|||
this.onImageTap, |
|||
this.tagsList = const [], |
|||
this.style = const {}, |
|||
}) : data = null, |
|||
assert(documentElement != null), |
|||
_anchorKey = anchorKey ?? GlobalKey(); |
|||
|
|||
/// A unique key for this Html widget to ensure uniqueness of anchors |
|||
final GlobalKey _anchorKey; |
|||
|
|||
/// 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.Element] |
|||
final dom.Element? documentElement; |
|||
|
|||
/// A function that defines what to do when a link is tapped |
|||
final OnTap? onLinkTap; |
|||
|
|||
/// A function that defines what to do when an anchor link is tapped. When this value is set, |
|||
/// the default anchor behaviour is overwritten. |
|||
final OnTap? onAnchorTap; |
|||
|
|||
/// A function that defines what to do when CSS fails to parse |
|||
final OnCssParseError? onCssParseError; |
|||
|
|||
/// A function that defines what to do when an image errors |
|||
final ImageErrorListener? onImageError; |
|||
|
|||
/// A parameter that should be set when the HTML widget is expected to be |
|||
/// flexible |
|||
final bool shrinkWrap; |
|||
|
|||
/// A function that defines what to do when an image is tapped |
|||
final OnTap? onImageTap; |
|||
|
|||
/// A list of HTML tags that are the only tags that are rendered. By default, this list is empty and all supported HTML tags are rendered. |
|||
final List<String> tagsList; |
|||
|
|||
/// Either return a custom widget for specific node types or return null to |
|||
/// fallback to the default rendering. |
|||
final Map<CustomRenderMatcher, CustomRender> customRenders; |
|||
|
|||
/// An API that allows you to override the default style for any HTML element |
|||
final Map<String, Style> style; |
|||
|
|||
static List<String> get tags => List<String>.from(HtmlElements.styledElements) |
|||
..addAll(HtmlElements.interactableElements) |
|||
..addAll(HtmlElements.replacedElements) |
|||
..addAll(HtmlElements.layoutElements) |
|||
..addAll(HtmlElements.tableCellElements) |
|||
..addAll(HtmlElements.tableDefinitionElements) |
|||
..addAll(HtmlElements.externalElements); |
|||
|
|||
@override |
|||
State<StatefulWidget> createState() => _HtmlState(); |
|||
} |
|||
|
|||
class _HtmlState extends State<Html> { |
|||
late dom.Element documentElement; |
|||
|
|||
@override |
|||
void initState() { |
|||
super.initState(); |
|||
documentElement = widget.data != null |
|||
? HtmlParser.parseHTML(widget.data!) |
|||
: widget.documentElement!; |
|||
} |
|||
|
|||
@override |
|||
void didUpdateWidget(Html oldWidget) { |
|||
super.didUpdateWidget(oldWidget); |
|||
if ((widget.data != null && oldWidget.data != widget.data) || |
|||
oldWidget.documentElement != widget.documentElement) { |
|||
documentElement = widget.data != null |
|||
? HtmlParser.parseHTML(widget.data!) |
|||
: widget.documentElement!; |
|||
} |
|||
} |
|||
|
|||
@override |
|||
Widget build(BuildContext context) { |
|||
return HtmlParser( |
|||
key: widget._anchorKey, |
|||
htmlData: documentElement, |
|||
onLinkTap: widget.onLinkTap, |
|||
onAnchorTap: widget.onAnchorTap, |
|||
onImageTap: widget.onImageTap, |
|||
onCssParseError: widget.onCssParseError, |
|||
onImageError: widget.onImageError, |
|||
shrinkWrap: widget.shrinkWrap, |
|||
selectable: false, |
|||
style: widget.style, |
|||
customRenders: {} |
|||
..addAll(widget.customRenders) |
|||
..addAll(generateDefaultRenders()), |
|||
tagsList: widget.tagsList.isEmpty ? Html.tags : widget.tagsList, |
|||
); |
|||
} |
|||
} |
|||
|
|||
class SelectableHtml extends StatefulWidget { |
|||
/// 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). |
|||
/// **documentElement** *required* takes in a Element of HTML data (required only for `Html.fromDom` and `Html.fromElement` constructor). |
|||
/// |
|||
/// **onLinkTap** This function is called whenever a link (`<a href>`) |
|||
/// is tapped. |
|||
/// |
|||
/// **onAnchorTap** This function is called whenever an anchor (#anchor-id) |
|||
/// is tapped. |
|||
/// |
|||
/// **tagsList** Tag names in this array will be the only tags rendered. By default, all tags that support selectable content are 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. 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. |
|||
/// |
|||
/// 2. No support for `customRender`, `customImageRender`, `onImageError`, `onImageTap`, `onMathError`, and `navigationDelegateForIframe`. |
|||
/// |
|||
/// 3. 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`). |
|||
|
|||
SelectableHtml({ |
|||
super.key, |
|||
GlobalKey? anchorKey, |
|||
required this.data, |
|||
this.onLinkTap, |
|||
this.onAnchorTap, |
|||
this.onCssParseError, |
|||
this.shrinkWrap = false, |
|||
this.style = const {}, |
|||
this.customRenders = const {}, |
|||
this.tagsList = const [], |
|||
this.selectionControls, |
|||
this.scrollPhysics, |
|||
}) : documentElement = null, |
|||
assert(data != null), |
|||
_anchorKey = anchorKey ?? GlobalKey(); |
|||
|
|||
SelectableHtml.fromDom({ |
|||
super.key, |
|||
GlobalKey? anchorKey, |
|||
@required dom.Document? document, |
|||
this.onLinkTap, |
|||
this.onAnchorTap, |
|||
this.onCssParseError, |
|||
this.shrinkWrap = false, |
|||
this.style = const {}, |
|||
this.customRenders = const {}, |
|||
this.tagsList = const [], |
|||
this.selectionControls, |
|||
this.scrollPhysics, |
|||
}) : data = null, |
|||
assert(document != null), |
|||
documentElement = document!.documentElement, |
|||
_anchorKey = anchorKey ?? GlobalKey(); |
|||
|
|||
SelectableHtml.fromElement({ |
|||
super.key, |
|||
GlobalKey? anchorKey, |
|||
@required this.documentElement, |
|||
this.onLinkTap, |
|||
this.onAnchorTap, |
|||
this.onCssParseError, |
|||
this.shrinkWrap = false, |
|||
this.style = const {}, |
|||
this.customRenders = const {}, |
|||
this.tagsList = const [], |
|||
this.selectionControls, |
|||
this.scrollPhysics, |
|||
}) : data = null, |
|||
assert(documentElement != null), |
|||
_anchorKey = anchorKey ?? GlobalKey(); |
|||
|
|||
/// A unique key for this Html widget to ensure uniqueness of anchors |
|||
final GlobalKey _anchorKey; |
|||
|
|||
/// 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.Element] |
|||
final dom.Element? documentElement; |
|||
|
|||
/// A function that defines what to do when a link is tapped |
|||
final OnTap? onLinkTap; |
|||
|
|||
/// A function that defines what to do when an anchor link is tapped. When this value is set, |
|||
/// the default anchor behaviour is overwritten. |
|||
final OnTap? onAnchorTap; |
|||
|
|||
/// 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 |
|||
/// have a flexible width, that doesn't always fill its maximum width |
|||
/// constraints. For example, auto horizontal margins are ignored, and |
|||
/// block-level elements only take up the width they need. |
|||
final bool shrinkWrap; |
|||
|
|||
/// A list of HTML tags that are the only tags that are rendered. By default, this list is empty and all supported HTML tags are rendered. |
|||
final List<String> tagsList; |
|||
|
|||
/// An API that allows you to override the default style for any HTML element |
|||
final Map<String, Style> style; |
|||
|
|||
/// Custom Selection controls allows you to override default toolbar and build custom toolbar |
|||
/// options |
|||
final TextSelectionControls? selectionControls; |
|||
|
|||
/// 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<CustomRenderMatcher, SelectableCustomRender> customRenders; |
|||
|
|||
static List<String> get tags => |
|||
List<String>.from(HtmlElements.selectableElements); |
|||
|
|||
@override |
|||
State<StatefulWidget> createState() => _SelectableHtmlState(); |
|||
} |
|||
|
|||
class _SelectableHtmlState extends State<SelectableHtml> { |
|||
late final dom.Element documentElement; |
|||
|
|||
@override |
|||
void initState() { |
|||
super.initState(); |
|||
documentElement = widget.data != null |
|||
? HtmlParser.parseHTML(widget.data!) |
|||
: widget.documentElement!; |
|||
} |
|||
|
|||
@override |
|||
Widget build(BuildContext context) { |
|||
return SizedBox( |
|||
width: widget.shrinkWrap ? null : MediaQuery.of(context).size.width, |
|||
child: HtmlParser( |
|||
key: widget._anchorKey, |
|||
htmlData: documentElement, |
|||
onLinkTap: widget.onLinkTap, |
|||
onAnchorTap: widget.onAnchorTap, |
|||
onImageTap: null, |
|||
onCssParseError: widget.onCssParseError, |
|||
onImageError: null, |
|||
shrinkWrap: widget.shrinkWrap, |
|||
selectable: true, |
|||
style: widget.style, |
|||
customRenders: {} |
|||
..addAll(widget.customRenders) |
|||
..addAll(generateDefaultRenders()), |
|||
tagsList: |
|||
widget.tagsList.isEmpty ? SelectableHtml.tags : widget.tagsList, |
|||
selectionControls: widget.selectionControls, |
|||
scrollPhysics: widget.scrollPhysics, |
|||
), |
|||
); |
|||
} |
|||
} |
@ -0,0 +1,877 @@ |
|||
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/material.dart'; |
|||
import 'package:html/dom.dart' as dom; |
|||
import 'package:html/parser.dart' as htmlparser; |
|||
import 'package:list_counter/list_counter.dart'; |
|||
import 'package:sonnat/core/html/custom_render.dart'; |
|||
import 'package:sonnat/core/html/flutter_html.dart'; |
|||
import 'package:sonnat/core/html/src/anchor.dart'; |
|||
import 'package:sonnat/core/html/src/css_box_widget.dart'; |
|||
import 'package:sonnat/core/html/src/css_parser.dart'; |
|||
import 'package:sonnat/core/html/src/html_elements.dart'; |
|||
import 'package:sonnat/core/html/src/layout_element.dart'; |
|||
import 'package:sonnat/core/html/src/style/fontsize.dart'; |
|||
import 'package:sonnat/core/html/src/style/length.dart'; |
|||
import 'package:sonnat/core/html/src/style/margin.dart'; |
|||
import 'package:sonnat/core/html/src/style/marker.dart'; |
|||
import 'package:sonnat/core/html/src/utils.dart'; |
|||
import 'package:sonnat/core/html/style.dart'; |
|||
|
|||
typedef OnTap = void Function( |
|||
String? url, |
|||
RenderContext context, |
|||
Map<String, String> attributes, |
|||
dom.Element? element, |
|||
); |
|||
typedef OnCssParseError = String? Function( |
|||
String css, |
|||
List<cssparser.Message> errors, |
|||
); |
|||
|
|||
class HtmlParser extends StatelessWidget { |
|||
final dom.Element htmlData; |
|||
final OnTap? onLinkTap; |
|||
final OnTap? onAnchorTap; |
|||
final OnTap? onImageTap; |
|||
final OnCssParseError? onCssParseError; |
|||
final ImageErrorListener? onImageError; |
|||
final bool shrinkWrap; |
|||
final bool selectable; |
|||
|
|||
final Map<String, Style> style; |
|||
final Map<CustomRenderMatcher, CustomRender> customRenders; |
|||
final List<String> tagsList; |
|||
final OnTap? internalOnAnchorTap; |
|||
final Html? root; |
|||
final TextSelectionControls? selectionControls; |
|||
final ScrollPhysics? scrollPhysics; |
|||
|
|||
final Map<String, Size> cachedImageSizes = {}; |
|||
|
|||
HtmlParser({ |
|||
required super.key, |
|||
required this.htmlData, |
|||
required this.onLinkTap, |
|||
required this.onAnchorTap, |
|||
required this.onImageTap, |
|||
required this.onCssParseError, |
|||
required this.onImageError, |
|||
required this.shrinkWrap, |
|||
required this.selectable, |
|||
required this.style, |
|||
required this.customRenders, |
|||
required this.tagsList, |
|||
this.root, |
|||
this.selectionControls, |
|||
this.scrollPhysics, |
|||
}) : internalOnAnchorTap = onAnchorTap ?? (key != null ? _handleAnchorTap(key, onLinkTap) : onLinkTap); |
|||
|
|||
@override |
|||
Widget build(BuildContext context) { |
|||
// Lexing Step |
|||
StyledElement lexedTree = lexDomTree( |
|||
htmlData, |
|||
customRenders.keys.toList(), |
|||
tagsList, |
|||
context, |
|||
this, |
|||
); |
|||
|
|||
// Styling Step |
|||
StyledElement styledTree = styleTree(lexedTree, htmlData, style, onCssParseError); |
|||
|
|||
// Processing Step |
|||
StyledElement processedTree = processTree(styledTree, MediaQuery.of(context).devicePixelRatio); |
|||
|
|||
// Parsing Step |
|||
InlineSpan parsedTree = parseTree( |
|||
RenderContext( |
|||
buildContext: context, |
|||
parser: this, |
|||
tree: processedTree, |
|||
style: processedTree.style, |
|||
), |
|||
processedTree, |
|||
); |
|||
|
|||
return CssBoxWidget.withInlineSpanChildren( |
|||
style: processedTree.style, |
|||
children: [parsedTree], |
|||
selectable: selectable, |
|||
scrollPhysics: scrollPhysics, |
|||
selectionControls: selectionControls, |
|||
shrinkWrap: shrinkWrap, |
|||
); |
|||
} |
|||
|
|||
/// [parseHTML] converts a string of HTML to a DOM element using the dart `html` library. |
|||
static dom.Element parseHTML(String data) { |
|||
return htmlparser.parse(data).documentElement!; |
|||
} |
|||
|
|||
/// [parseCss] converts a string of CSS to a CSS stylesheet using the dart `csslib` library. |
|||
static css.StyleSheet parseCss(String data) { |
|||
return cssparser.parse(data); |
|||
} |
|||
|
|||
/// [lexDomTree] converts a DOM document to a simplified tree of [StyledElement]s. |
|||
static StyledElement lexDomTree( |
|||
dom.Element html, |
|||
List<CustomRenderMatcher> customRenderMatchers, |
|||
List<String> tagsList, |
|||
BuildContext context, |
|||
HtmlParser parser, |
|||
) { |
|||
StyledElement tree = StyledElement( |
|||
name: '[Tree Root]', |
|||
children: <StyledElement>[], |
|||
node: html, |
|||
style: Style.fromTextStyle(Theme.of(context).textTheme.bodyMedium!), |
|||
); |
|||
|
|||
for (var node in html.nodes) { |
|||
tree.children.add(_recursiveLexer( |
|||
node, |
|||
customRenderMatchers, |
|||
tagsList, |
|||
context, |
|||
parser, |
|||
)); |
|||
} |
|||
|
|||
return tree; |
|||
} |
|||
|
|||
/// [_recursiveLexer] is the recursive worker function for [lexDomTree]. |
|||
/// |
|||
/// It runs the parse functions of every type of |
|||
/// element and returns a [StyledElement] tree representing the element. |
|||
static StyledElement _recursiveLexer( |
|||
dom.Node node, |
|||
List<CustomRenderMatcher> customRenderMatchers, |
|||
List<String> tagsList, |
|||
BuildContext context, |
|||
HtmlParser parser, |
|||
) { |
|||
List<StyledElement> children = <StyledElement>[]; |
|||
|
|||
for (var childNode in node.nodes) { |
|||
children.add(_recursiveLexer( |
|||
childNode, |
|||
customRenderMatchers, |
|||
tagsList, |
|||
context, |
|||
parser, |
|||
)); |
|||
} |
|||
if (node is dom.Element) { |
|||
if (!tagsList.contains(node.localName)) { |
|||
return EmptyContentElement(); |
|||
} |
|||
if (HtmlElements.styledElements.contains(node.localName)) { |
|||
return parseStyledElement(node, children); |
|||
} |
|||
if (HtmlElements.interactableElements.contains(node.localName)) { |
|||
return parseInteractableElement(node, children); |
|||
} |
|||
if (HtmlElements.replacedElements.contains(node.localName)) { |
|||
return parseReplacedElement(node, children); |
|||
} |
|||
if (HtmlElements.layoutElements.contains(node.localName)) { |
|||
return parseLayoutElement(node, children); |
|||
} |
|||
if (HtmlElements.tableCellElements.contains(node.localName)) { |
|||
return parseTableCellElement(node, children); |
|||
} |
|||
if (HtmlElements.tableDefinitionElements.contains(node.localName)) { |
|||
return parseTableDefinitionElement(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.bodyMedium!), |
|||
), |
|||
)) { |
|||
return tree; |
|||
} |
|||
} |
|||
return EmptyContentElement(); |
|||
} |
|||
} else if (node is dom.Text) { |
|||
return TextContentElement( |
|||
text: node.text, |
|||
style: Style(), |
|||
element: node.parent, |
|||
node: node, |
|||
); |
|||
} else { |
|||
return EmptyContentElement(); |
|||
} |
|||
} |
|||
|
|||
static Map<String, Map<String, List<css.Expression>>> _getExternalCssDeclarations( |
|||
List<dom.Element> styles, OnCssParseError? errorHandler) { |
|||
String fullCss = ''; |
|||
for (final e in styles) { |
|||
fullCss = fullCss + e.innerHtml; |
|||
} |
|||
if (fullCss.isNotEmpty) { |
|||
final declarations = parseExternalCss(fullCss, errorHandler); |
|||
return declarations; |
|||
} else { |
|||
return {}; |
|||
} |
|||
} |
|||
|
|||
static StyledElement _applyExternalCss( |
|||
Map<String, Map<String, List<css.Expression>>> declarations, StyledElement tree) { |
|||
declarations.forEach((key, style) { |
|||
try { |
|||
if (tree.matchesSelector(key)) { |
|||
tree.style = tree.style.merge(declarationsToStyle(style)); |
|||
} |
|||
} catch (_) {} |
|||
}); |
|||
|
|||
for (var element in tree.children) { |
|||
_applyExternalCss(declarations, element); |
|||
} |
|||
|
|||
return tree; |
|||
} |
|||
|
|||
static StyledElement _applyInlineStyles(StyledElement tree, OnCssParseError? errorHandler) { |
|||
if (tree.attributes.containsKey('style')) { |
|||
final newStyle = inlineCssToStyle(tree.attributes['style'], errorHandler); |
|||
if (newStyle != null) { |
|||
tree.style = tree.style.merge(newStyle); |
|||
} |
|||
} |
|||
|
|||
for (var element in tree.children) { |
|||
_applyInlineStyles(element, errorHandler); |
|||
} |
|||
return tree; |
|||
} |
|||
|
|||
/// [applyCustomStyles] applies the [Style] objects passed into the [Html] |
|||
/// widget onto the [StyledElement] tree, no cascading of styles is done at this point. |
|||
static StyledElement _applyCustomStyles(Map<String, Style> style, StyledElement tree) { |
|||
style.forEach((key, style) { |
|||
try { |
|||
if (tree.matchesSelector(key)) { |
|||
tree.style = tree.style.merge(style); |
|||
} |
|||
} catch (_) {} |
|||
}); |
|||
for (var element in tree.children) { |
|||
_applyCustomStyles(style, element); |
|||
} |
|||
|
|||
return tree; |
|||
} |
|||
|
|||
/// [_cascadeStyles] cascades all of the inherited styles down the tree, applying them to each |
|||
/// child that doesn't specify a different style. |
|||
static StyledElement _cascadeStyles(Map<String, Style> style, StyledElement tree) { |
|||
for (var child in tree.children) { |
|||
child.style = tree.style.copyOnlyInherited(child.style); |
|||
_cascadeStyles(style, child); |
|||
} |
|||
|
|||
return tree; |
|||
} |
|||
|
|||
/// [styleTree] takes the lexed [StyleElement] tree and applies external, |
|||
/// inline, and custom CSS/Flutter styles, and then cascades the styles down the tree. |
|||
static StyledElement styleTree( |
|||
StyledElement tree, dom.Element htmlData, Map<String, Style> style, OnCssParseError? onCssParseError) { |
|||
Map<String, Map<String, List<css.Expression>>> declarations = |
|||
_getExternalCssDeclarations(htmlData.getElementsByTagName('style'), onCssParseError); |
|||
|
|||
StyledElement? externalCssStyledTree; |
|||
if (declarations.isNotEmpty) { |
|||
externalCssStyledTree = _applyExternalCss(declarations, tree); |
|||
} |
|||
tree = _applyInlineStyles(externalCssStyledTree ?? tree, onCssParseError); |
|||
tree = _applyCustomStyles(style, tree); |
|||
tree = _cascadeStyles(style, tree); |
|||
return tree; |
|||
} |
|||
|
|||
/// [processTree] optimizes the [StyledElement] tree so all [BlockElement]s are |
|||
/// on the first level, redundant levels are collapsed, empty elements are |
|||
/// removed, and specialty elements are processed. |
|||
static StyledElement processTree(StyledElement tree, double devicePixelRatio) { |
|||
tree = _processInternalWhitespace(tree); |
|||
tree = _processInlineWhitespace(tree); |
|||
tree = _removeEmptyElements(tree); |
|||
|
|||
tree = _calculateRelativeValues(tree, devicePixelRatio); |
|||
tree = _preprocessListMarkers(tree); |
|||
tree = _processCounters(tree); |
|||
tree = _processListMarkers(tree); |
|||
tree = _processBeforesAndAfters(tree); |
|||
tree = _collapseMargins(tree); |
|||
return tree; |
|||
} |
|||
|
|||
/// [parseTree] converts a tree of [StyledElement]s to an [InlineSpan] tree. |
|||
/// |
|||
/// [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 |
|||
// inherit the correct style |
|||
RenderContext newContext = RenderContext( |
|||
buildContext: context.buildContext, |
|||
parser: this, |
|||
tree: tree, |
|||
style: context.style.copyOnlyInherited(tree.style), |
|||
key: AnchorKey.of(key, tree), |
|||
); |
|||
|
|||
for (final entry in customRenders.keys) { |
|||
if (entry.call(newContext)) { |
|||
List<InlineSpan> buildChildren() => tree.children.map((tree) => parseTree(newContext, tree)).toList(); |
|||
if (newContext.parser.selectable && customRenders[entry] is SelectableCustomRender) { |
|||
List<TextSpan> 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 WidgetSpan( |
|||
child: CssBoxWidget( |
|||
style: tree.style, |
|||
shrinkWrap: newContext.parser.shrinkWrap, |
|||
childIsReplaced: true, |
|||
child: customRenders[entry]!.widget!.call(newContext, buildChildren), |
|||
), |
|||
); |
|||
} |
|||
} |
|||
return const WidgetSpan(child: SizedBox(height: 0, width: 0)); |
|||
} |
|||
|
|||
static OnTap _handleAnchorTap(Key key, OnTap? onLinkTap) => (url, context, attributes, element) { |
|||
if (url?.startsWith('#') == true) { |
|||
final anchorContext = AnchorKey.forId(key, url!.substring(1))?.currentContext; |
|||
if (anchorContext != null) { |
|||
Scrollable.ensureVisible(anchorContext); |
|||
} |
|||
return; |
|||
} |
|||
onLinkTap?.call(url, context, attributes, element); |
|||
}; |
|||
|
|||
/// [processWhitespace] removes unnecessary whitespace from the StyledElement tree. |
|||
/// |
|||
/// The criteria for determining which whitespace is replaceable is outlined |
|||
/// at https://www.w3.org/TR/css-text-3/ |
|||
/// and summarized at https://medium.com/@patrickbrosset/when-does-white-space-matter-in-html-b90e8a7cdd33 |
|||
static StyledElement _processInternalWhitespace(StyledElement tree) { |
|||
if ((tree.style.whiteSpace ?? WhiteSpace.normal) == WhiteSpace.pre) { |
|||
// Preserve this whitespace |
|||
} else if (tree is TextContentElement) { |
|||
tree.text = _removeUnnecessaryWhitespace(tree.text!); |
|||
} else { |
|||
tree.children.forEach(_processInternalWhitespace); |
|||
} |
|||
return tree; |
|||
} |
|||
|
|||
/// [_processInlineWhitespace] is responsible for removing redundant whitespace |
|||
/// between and among inline elements. It does so by creating a boolean [Context] |
|||
/// and passing it to the [_processInlineWhitespaceRecursive] function. |
|||
static StyledElement _processInlineWhitespace(StyledElement tree) { |
|||
tree = _processInlineWhitespaceRecursive(tree, Context(false)); |
|||
return tree; |
|||
} |
|||
|
|||
/// [_processInlineWhitespaceRecursive] analyzes the whitespace between and among different |
|||
/// inline elements, and replaces any instance of two or more spaces with a single space, according |
|||
/// to the w3's HTML whitespace processing specification linked to above. |
|||
static StyledElement _processInlineWhitespaceRecursive( |
|||
StyledElement tree, |
|||
Context<bool> keepLeadingSpace, |
|||
) { |
|||
if (tree is TextContentElement) { |
|||
/// initialize indices to negative numbers to make conditionals a little easier |
|||
int textIndex = -1; |
|||
int elementIndex = -1; |
|||
|
|||
/// initialize parent after to a whitespace to account for elements that are |
|||
/// the last child in the list of elements |
|||
String parentAfterText = ' '; |
|||
|
|||
/// find the index of the text in the current tree |
|||
if ((tree.element?.nodes.length ?? 0) >= 1) { |
|||
textIndex = tree.element?.nodes.indexWhere((element) => element == tree.node) ?? -1; |
|||
} |
|||
|
|||
/// get the parent nodes |
|||
dom.NodeList? parentNodes = tree.element?.parent?.nodes; |
|||
|
|||
/// find the index of the tree itself in the parent nodes |
|||
if ((parentNodes?.length ?? 0) >= 1) { |
|||
elementIndex = parentNodes?.indexWhere((element) => element == tree.element) ?? -1; |
|||
} |
|||
|
|||
/// if the tree is any node except the last node in the node list and the |
|||
/// next node in the node list is a text node, then get its text. Otherwise |
|||
/// the next node will be a [dom.Element], so keep unwrapping that until |
|||
/// we get the underlying text node, and finally get its text. |
|||
if (elementIndex < (parentNodes?.length ?? 1) - 1 && parentNodes?[elementIndex + 1] is dom.Text) { |
|||
parentAfterText = parentNodes?[elementIndex + 1].text ?? ' '; |
|||
} else if (elementIndex < (parentNodes?.length ?? 1) - 1) { |
|||
var parentAfter = parentNodes?[elementIndex + 1]; |
|||
while (parentAfter is dom.Element) { |
|||
if (parentAfter.nodes.isNotEmpty) { |
|||
parentAfter = parentAfter.nodes.first; |
|||
} else { |
|||
break; |
|||
} |
|||
} |
|||
parentAfterText = parentAfter?.text ?? ' '; |
|||
} |
|||
|
|||
/// If the text is the first element in the current tree node list, it |
|||
/// starts with a whitespace, it isn't a line break, either the |
|||
/// whitespace is unnecessary or it is a block element, and either it is |
|||
/// first element in the parent node list or the previous element |
|||
/// in the parent node list ends with a whitespace, delete it. |
|||
/// |
|||
/// We should also delete the whitespace at any point in the node list |
|||
/// if the previous element is a <br> because that tag makes the element |
|||
/// act like a block element. |
|||
if (textIndex < 1 && |
|||
tree.text!.startsWith(' ') && |
|||
tree.element?.localName != 'br' && |
|||
(!keepLeadingSpace.data || tree.style.display == Display.block) && |
|||
(elementIndex < 1 || |
|||
(elementIndex >= 1 && |
|||
parentNodes?[elementIndex - 1] is dom.Text && |
|||
parentNodes![elementIndex - 1].text!.endsWith(' ')))) { |
|||
tree.text = tree.text!.replaceFirst(' ', ''); |
|||
} else if (textIndex >= 1 && |
|||
tree.text!.startsWith(' ') && |
|||
tree.element?.nodes[textIndex - 1] is dom.Element && |
|||
(tree.element?.nodes[textIndex - 1] as dom.Element).localName == 'br') { |
|||
tree.text = tree.text!.replaceFirst(' ', ''); |
|||
} |
|||
|
|||
/// If the text is the last element in the current tree node list, it isn't |
|||
/// a line break, and the next text node starts with a whitespace, |
|||
/// update the [Context] to signify to that next text node whether it should |
|||
/// keep its whitespace. This is based on whether the current text ends with a |
|||
/// whitespace. |
|||
if (textIndex == (tree.element?.nodes.length ?? 1) - 1 && |
|||
tree.element?.localName != 'br' && |
|||
parentAfterText.startsWith(' ')) { |
|||
keepLeadingSpace.data = !tree.text!.endsWith(' '); |
|||
} |
|||
} |
|||
|
|||
for (var element in tree.children) { |
|||
_processInlineWhitespaceRecursive(element, keepLeadingSpace); |
|||
} |
|||
|
|||
return tree; |
|||
} |
|||
|
|||
/// [removeUnnecessaryWhitespace] removes "unnecessary" white space from the given String. |
|||
/// |
|||
/// The steps for removing this whitespace are as follows: |
|||
/// (1) Remove any whitespace immediately preceding or following a newline. |
|||
/// (2) Replace all newlines with a space |
|||
/// (3) Replace all tabs with a space |
|||
/// (4) Replace any instances of two or more spaces with a single space. |
|||
static String _removeUnnecessaryWhitespace(String text) { |
|||
return text |
|||
.replaceAll(RegExp('\\ *(?=\n)'), '\n') |
|||
.replaceAll(RegExp('(?:\n)\\ *'), '\n') |
|||
.replaceAll('\n', ' ') |
|||
.replaceAll('\t', ' ') |
|||
.replaceAll(RegExp(' {2,}'), ' '); |
|||
} |
|||
|
|||
/// [preprocessListMarkers] adds marker pseudo elements to the front of all list |
|||
/// items. |
|||
static StyledElement _preprocessListMarkers(StyledElement tree) { |
|||
tree.style.listStylePosition ??= ListStylePosition.outside; |
|||
|
|||
if (tree.style.display == Display.listItem) { |
|||
// Add the marker pseudo-element if it doesn't exist |
|||
tree.style.marker ??= Marker( |
|||
content: Content.normal, |
|||