From 35c1130191f378776682c62af4507541d03f0ae9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=AB=A0=E6=96=87=E8=BD=A9?= <12812285557@qq.com> Date: Fri, 23 Dec 2022 13:47:23 +0800 Subject: [PATCH] 1.4.3 --- bytedesk_demo/lib/page/chat_type_page.dart | 2 +- .../windows/flutter/generated_plugins.cmake | 8 + .../example/android/app/build.gradle | 2 +- .../ios/Flutter/AppFrameworkInfo.plist | 2 +- bytedesk_kefu/example/ios/Podfile.lock | 28 +- .../ios/Runner.xcodeproj/project.pbxproj | 6 +- .../example/ios/Runner/Info-Debug.plist | 4 + .../example/ios/Runner/Info-Profile.plist | 4 + .../example/ios/Runner/Info-Release.plist | 4 + .../example/lib/page/chat_type_page.dart | 29 +- .../Flutter/GeneratedPluginRegistrant.swift | 6 +- bytedesk_kefu/example/pubspec.yaml | 2 +- .../lib/ui/chat/page/chat_webview_page.dart | 163 ++- .../lib/ui/chat/widget/message_widget.dart | 2 +- .../ui/faq/page/help_article_detail_page.dart | 1 - .../src/category_icons.dart | 2 +- .../emoji_picker_flutter/src/config.dart | 4 +- .../src/default_emoji_picker_view.dart | 8 +- .../emoji_picker_flutter/src/emoji_lists.dart | 2 +- .../src/emoji_picker.dart | 97 +- .../src/emoji_picker_builder.dart | 4 +- .../src/emoji_picker_internal_utils.dart | 2 +- .../src/emoji_picker_utils.dart | 2 +- .../src/emoji_view_state.dart | 2 +- .../src/recent_emoji.dart | 2 +- .../vendors/flutter_html/custom_render.dart | 635 ++++++++++++ .../vendors/flutter_html/flutter_html.dart | 371 +++++++ .../lib/vendors/flutter_html/html_parser.dart | 961 +++++++++++++++++ .../lib/vendors/flutter_html/src/anchor.dart | 42 + .../vendors/flutter_html/src/css_parser.dart | 981 ++++++++++++++++++ .../flutter_html/src/html_elements.dart | 283 +++++ .../src/interactable_element.dart | 61 ++ .../flutter_html/src/layout_element.dart | 214 ++++ .../flutter_html/src/replaced_element.dart | 166 +++ .../flutter_html/src/styled_element.dart | 412 ++++++++ .../lib/vendors/flutter_html/src/utils.dart | 88 ++ .../lib/vendors/flutter_html/style.dart | 590 +++++++++++ bytedesk_kefu/pubspec.yaml | 6 +- 38 files changed, 5022 insertions(+), 176 deletions(-) create mode 100644 bytedesk_kefu/lib/vendors/flutter_html/custom_render.dart create mode 100644 bytedesk_kefu/lib/vendors/flutter_html/flutter_html.dart create mode 100644 bytedesk_kefu/lib/vendors/flutter_html/html_parser.dart create mode 100644 bytedesk_kefu/lib/vendors/flutter_html/src/anchor.dart create mode 100644 bytedesk_kefu/lib/vendors/flutter_html/src/css_parser.dart create mode 100644 bytedesk_kefu/lib/vendors/flutter_html/src/html_elements.dart create mode 100644 bytedesk_kefu/lib/vendors/flutter_html/src/interactable_element.dart create mode 100644 bytedesk_kefu/lib/vendors/flutter_html/src/layout_element.dart create mode 100644 bytedesk_kefu/lib/vendors/flutter_html/src/replaced_element.dart create mode 100644 bytedesk_kefu/lib/vendors/flutter_html/src/styled_element.dart create mode 100644 bytedesk_kefu/lib/vendors/flutter_html/src/utils.dart create mode 100644 bytedesk_kefu/lib/vendors/flutter_html/style.dart diff --git a/bytedesk_demo/lib/page/chat_type_page.dart b/bytedesk_demo/lib/page/chat_type_page.dart index ad36f7f..9ab684e 100755 --- a/bytedesk_demo/lib/page/chat_type_page.dart +++ b/bytedesk_demo/lib/page/chat_type_page.dart @@ -296,7 +296,7 @@ class _ChatTypePageState extends State { print('h5 chat'); // 注意: 登录后台->客服管理->技能组(或客服账号)->获取客服代码 获取相应URL String url = - "https://h2.kefux.cn/chat/h5/index.html?sub=vip&uid=201808221551193&wid=201807171659201&type=workGroup&aid=&hidenav=1&ph=ph"; + "https://h2.kefux.com/chat/h5/index.html?sub=vip&uid=201808221551193&wid=201807171659201&type=workGroup&aid=&hidenav=1&ph=ph"; String title = 'H5在线客服演示'; BytedeskKefu.startH5Chat(context, url, title); }, diff --git a/bytedesk_demo/windows/flutter/generated_plugins.cmake b/bytedesk_demo/windows/flutter/generated_plugins.cmake index 406ac4c..a53a582 100644 --- a/bytedesk_demo/windows/flutter/generated_plugins.cmake +++ b/bytedesk_demo/windows/flutter/generated_plugins.cmake @@ -7,6 +7,9 @@ list(APPEND FLUTTER_PLUGIN_LIST url_launcher_windows ) +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + set(PLUGIN_BUNDLED_LIBRARIES) foreach(plugin ${FLUTTER_PLUGIN_LIST}) @@ -15,3 +18,8 @@ foreach(plugin ${FLUTTER_PLUGIN_LIST}) list(APPEND PLUGIN_BUNDLED_LIBRARIES $) list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/windows plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/bytedesk_kefu/example/android/app/build.gradle b/bytedesk_kefu/example/android/app/build.gradle index 8f5d910..b514678 100644 --- a/bytedesk_kefu/example/android/app/build.gradle +++ b/bytedesk_kefu/example/android/app/build.gradle @@ -30,7 +30,7 @@ android { defaultConfig { // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). applicationId "com.bytedesk.bytedesk_kefu_example" - minSdkVersion 19 + minSdkVersion 20 targetSdkVersion 30 versionCode flutterVersionCode.toInteger() versionName flutterVersionName diff --git a/bytedesk_kefu/example/ios/Flutter/AppFrameworkInfo.plist b/bytedesk_kefu/example/ios/Flutter/AppFrameworkInfo.plist index 8d4492f..9625e10 100644 --- a/bytedesk_kefu/example/ios/Flutter/AppFrameworkInfo.plist +++ b/bytedesk_kefu/example/ios/Flutter/AppFrameworkInfo.plist @@ -21,6 +21,6 @@ CFBundleVersion 1.0 MinimumOSVersion - 9.0 + 11.0 diff --git a/bytedesk_kefu/example/ios/Podfile.lock b/bytedesk_kefu/example/ios/Podfile.lock index 9a7d1d5..7877ace 100644 --- a/bytedesk_kefu/example/ios/Podfile.lock +++ b/bytedesk_kefu/example/ios/Podfile.lock @@ -5,14 +5,14 @@ PODS: - Flutter - devicelocale (0.0.1): - Flutter - - DKImagePickerController/Core (4.3.2): + - DKImagePickerController/Core (4.3.4): - DKImagePickerController/ImageDataManager - DKImagePickerController/Resource - - DKImagePickerController/ImageDataManager (4.3.2) - - DKImagePickerController/PhotoGallery (4.3.2): + - DKImagePickerController/ImageDataManager (4.3.4) + - DKImagePickerController/PhotoGallery (4.3.4): - DKImagePickerController/Core - DKPhotoGallery - - DKImagePickerController/Resource (4.3.2) + - DKImagePickerController/Resource (4.3.4) - DKPhotoGallery (0.0.17): - DKPhotoGallery/Core (= 0.0.17) - DKPhotoGallery/Model (= 0.0.17) @@ -54,9 +54,9 @@ PODS: - Flutter - path_provider_ios (0.0.1): - Flutter - - SDWebImage (5.12.5): - - SDWebImage/Core (= 5.12.5) - - SDWebImage/Core (5.12.5) + - SDWebImage (5.14.2): + - SDWebImage/Core (= 5.14.2) + - SDWebImage/Core (5.14.2) - shared_preferences_ios (0.0.1): - Flutter - sqflite (0.0.2): @@ -138,17 +138,17 @@ SPEC CHECKSUMS: bytedesk_kefu: ca69f7b243932a665dc7001be5a8e04fe7f30c57 device_info: d7d233b645a32c40dfdc212de5cf646ca482f175 devicelocale: b22617f40038496deffba44747101255cee005b0 - DKImagePickerController: b5eb7f7a388e4643264105d648d01f727110fc3d + DKImagePickerController: b512c28220a2b8ac7419f21c491fc8534b7601ac DKPhotoGallery: fdfad5125a9fdda9cc57df834d49df790dbb4179 - file_picker: 3e6c3790de664ccf9b882732d9db5eaf6b8d4eb1 - Flutter: 50d75fe2f02b26cc09d224853bb45737f8b3214a - fluttertoast: 16fbe6039d06a763f3533670197d01fc73459037 + file_picker: 817ab1d8cd2da9d2da412a417162deee3500fc95 + Flutter: f04841e97a9d0b0a8025694d0796dd46242b2854 + fluttertoast: eb263d302cc92e04176c053d2385237e9f43fad0 FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a image_gallery_saver: 259eab68fb271cfd57d599904f7acdc7832e7ef2 image_picker_ios: b786a5dcf033a8336a657191401bfdf12017dabb package_info: 873975fc26034f0b863a300ad47e7f1ac6c7ec62 path_provider_ios: 14f3d2fd28c4fdb42f44e0f751d12861c43cee02 - SDWebImage: 0905f1b7760fc8ac4198cae0036600d67478751e + SDWebImage: b9a731e1d6307f44ca703b3976d18c24ca561e84 shared_preferences_ios: 548a61f8053b9b8a49ac19c1ffbc8b92c50d68ad sqflite: 6d358c025f5b867b29ed92fc697fd34924e11904 SwiftyGif: 6c3eafd0ce693cad58bb63d2b2fb9bacb8552780 @@ -156,8 +156,8 @@ SPEC CHECKSUMS: url_launcher_ios: 839c58cdb4279282219f5e248c3321761ff3c4de video_player_avfoundation: e489aac24ef5cf7af82702979ed16f2a5ef84cff wakelock: d0fc7c864128eac40eba1617cb5264d9c940b46f - webview_flutter_wkwebview: 005fbd90c888a42c5690919a1527ecc6649e1162 + webview_flutter_wkwebview: b7e70ef1ddded7e69c796c7390ee74180182971f -PODFILE CHECKSUM: 3992017b8e295cef2daf25d4508056e8c5de3123 +PODFILE CHECKSUM: b91427fd94287f9a2f30769828f3c2f6d1e8889a COCOAPODS: 1.11.3 diff --git a/bytedesk_kefu/example/ios/Runner.xcodeproj/project.pbxproj b/bytedesk_kefu/example/ios/Runner.xcodeproj/project.pbxproj index b52c80e..1031b7c 100644 --- a/bytedesk_kefu/example/ios/Runner.xcodeproj/project.pbxproj +++ b/bytedesk_kefu/example/ios/Runner.xcodeproj/project.pbxproj @@ -354,7 +354,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 9.0; + IPHONEOS_DEPLOYMENT_TARGET = 11.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -429,7 +429,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 9.0; + IPHONEOS_DEPLOYMENT_TARGET = 11.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -478,7 +478,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 9.0; + IPHONEOS_DEPLOYMENT_TARGET = 11.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; diff --git a/bytedesk_kefu/example/ios/Runner/Info-Debug.plist b/bytedesk_kefu/example/ios/Runner/Info-Debug.plist index e8b5298..039c41a 100644 --- a/bytedesk_kefu/example/ios/Runner/Info-Debug.plist +++ b/bytedesk_kefu/example/ios/Runner/Info-Debug.plist @@ -70,11 +70,15 @@ UIViewControllerBasedStatusBarAppearance + CADisableMinimumFrameDurationOnPhone + NSLocalNetworkUsageDescription allow flutter to access localhost network NSBonjourServices _dartobservatory._tcp + UIApplicationSupportsIndirectInputEvents + diff --git a/bytedesk_kefu/example/ios/Runner/Info-Profile.plist b/bytedesk_kefu/example/ios/Runner/Info-Profile.plist index 935ec6f..a4f80cf 100644 --- a/bytedesk_kefu/example/ios/Runner/Info-Profile.plist +++ b/bytedesk_kefu/example/ios/Runner/Info-Profile.plist @@ -70,5 +70,9 @@ UIViewControllerBasedStatusBarAppearance + CADisableMinimumFrameDurationOnPhone + + UIApplicationSupportsIndirectInputEvents + diff --git a/bytedesk_kefu/example/ios/Runner/Info-Release.plist b/bytedesk_kefu/example/ios/Runner/Info-Release.plist index 935ec6f..a4f80cf 100644 --- a/bytedesk_kefu/example/ios/Runner/Info-Release.plist +++ b/bytedesk_kefu/example/ios/Runner/Info-Release.plist @@ -70,5 +70,9 @@ UIViewControllerBasedStatusBarAppearance + CADisableMinimumFrameDurationOnPhone + + UIApplicationSupportsIndirectInputEvents + diff --git a/bytedesk_kefu/example/lib/page/chat_type_page.dart b/bytedesk_kefu/example/lib/page/chat_type_page.dart index 8c79e2c..9027783 100755 --- a/bytedesk_kefu/example/lib/page/chat_type_page.dart +++ b/bytedesk_kefu/example/lib/page/chat_type_page.dart @@ -91,9 +91,9 @@ class _ChatTypePageState extends State { "content": "商品详情", // 可自定义, 类型为字符串 "price": "9.99", // 可自定义, 类型为字符串 "url": - "https://item.m.jd.com/product/12172344.html", // 必须为url网址, 类型为字符串 + "https://item.m.jd.com/product/12172344.html", // 必须为url网址, 类型为字符串 "imageUrl": - "https://bytedesk.oss-cn-shenzhen.aliyuncs.com/images/123.webp", //必须为图片网址, 类型为字符串 + "https://bytedesk.oss-cn-shenzhen.aliyuncs.com/images/123.webp", //必须为图片网址, 类型为字符串 "id": 123, // 可自定义 "categoryCode": "100010003", // 可自定义, 类型为字符串 "client": "flutter" // 可自定义, 类型为字符串 @@ -115,9 +115,9 @@ class _ChatTypePageState extends State { "content": "商品详情", // 可自定义, 类型为字符串 "price": "9.99", // 可自定义, 类型为字符串 "url": - "https://item.m.jd.com/product/12172344.html", // 必须为url网址, 类型为字符串 + "https://item.m.jd.com/product/12172344.html", // 必须为url网址, 类型为字符串 "imageUrl": - "https://bytedesk.oss-cn-shenzhen.aliyuncs.com/images/123.webp", //必须为图片网址, 类型为字符串 + "https://bytedesk.oss-cn-shenzhen.aliyuncs.com/images/123.webp", //必须为图片网址, 类型为字符串 "id": 123, // 可自定义 "categoryCode": "100010003", // 可自定义, 类型为字符串 "client": "flutter", // 可自定义, 类型为字符串 @@ -167,7 +167,7 @@ class _ChatTypePageState extends State { "price": "9.99", "url": "https://item.m.jd.com/product/12172344.html", "imageUrl": - "https://bytedesk.oss-cn-shenzhen.aliyuncs.com/images/123.webp", + "https://bytedesk.oss-cn-shenzhen.aliyuncs.com/images/123.webp", "id": 123, "categoryCode": "100010003", "client": "flutter" @@ -190,7 +190,7 @@ class _ChatTypePageState extends State { "price": "9.99", "url": "https://item.m.jd.com/product/12172344.html", "imageUrl": - "https://bytedesk.oss-cn-shenzhen.aliyuncs.com/images/123.webp", + "https://bytedesk.oss-cn-shenzhen.aliyuncs.com/images/123.webp", "id": 123, "categoryCode": "100010003", "client": "flutter", @@ -224,8 +224,9 @@ class _ChatTypePageState extends State { onTap: () { print('h5 chat'); // 注意: 登录后台->客服管理->技能组(或客服账号)->获取客服代码 获取相应URL - String url = - "https://h2.kefux.cn/chat/h5/index.html?sub=vip&uid=201808221551193&wid=201807171659201&type=workGroup&aid=&hidenav=1&ph=ph"; + String url = "https://h2.kefux.com/chat/h5/index.html?sub=vip&uid=201808221551193&wid=201807171659201&type=workGroup&aid=&hidenav=1&p"; + // String url = + // "http://127.0.0.1:8887/chat/h5/index.html?sub=vip&uid=201808221551193&wid=201807171659201&type=workGroup&aid=201808221551193&history=0&lang=cn&v2robot=0&p"; String title = 'H5在线客服演示'; BytedeskKefu.startH5Chat(context, url, title); }, @@ -236,12 +237,12 @@ class _ChatTypePageState extends State { } void _getUnreadCountVisitor() { - // 获取指定客服在线状态 + // 获取消息未读数目 BytedeskKefu.getUnreadCountVisitor().then((count) => { - print('unreadcount:' + count), - setState(() { - _unreadMessageCount = count; - }) - }); + print('unreadcount:' + count), + setState(() { + _unreadMessageCount = count; + }) + }); } } diff --git a/bytedesk_kefu/example/macos/Flutter/GeneratedPluginRegistrant.swift b/bytedesk_kefu/example/macos/Flutter/GeneratedPluginRegistrant.swift index b7296b3..f40b803 100644 --- a/bytedesk_kefu/example/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/bytedesk_kefu/example/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,20 +5,22 @@ import FlutterMacOS import Foundation -import audioplayers import bytedesk_kefu +import devicelocale import package_info import path_provider_macos import shared_preferences_macos import sqflite +import url_launcher_macos import wakelock_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { - AudioplayersPlugin.register(with: registry.registrar(forPlugin: "AudioplayersPlugin")) BytedeskKefuPlugin.register(with: registry.registrar(forPlugin: "BytedeskKefuPlugin")) + DevicelocalePlugin.register(with: registry.registrar(forPlugin: "DevicelocalePlugin")) FLTPackageInfoPlugin.register(with: registry.registrar(forPlugin: "FLTPackageInfoPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) + UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) WakelockMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockMacosPlugin")) } diff --git a/bytedesk_kefu/example/pubspec.yaml b/bytedesk_kefu/example/pubspec.yaml index ab7ea6a..2816f2d 100644 --- a/bytedesk_kefu/example/pubspec.yaml +++ b/bytedesk_kefu/example/pubspec.yaml @@ -6,7 +6,7 @@ description: Demonstrates how to use the bytedesk_kefu plugin. publish_to: 'none' # Remove this line if you wish to publish to pub.dev environment: - sdk: ">=2.12.0 <3.0.0" + sdk: ">=2.14.0 <3.0.0" dependencies: flutter: diff --git a/bytedesk_kefu/lib/ui/chat/page/chat_webview_page.dart b/bytedesk_kefu/lib/ui/chat/page/chat_webview_page.dart index 4b5afde..eeef811 100755 --- a/bytedesk_kefu/lib/ui/chat/page/chat_webview_page.dart +++ b/bytedesk_kefu/lib/ui/chat/page/chat_webview_page.dart @@ -1,19 +1,27 @@ import 'dart:async'; +// import 'dart:convert'; +import 'dart:io'; +// import 'dart:typed_data'; // import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:webview_flutter/webview_flutter.dart'; +// import 'package:flutter_webview_pro/webview_flutter.dart'; +// import 'package:path_provider/path_provider.dart'; +// TODO: 支持访问相册发送图片 // TODO: 支持右上角在其他浏览器打开 class ChatWebViewPage extends StatefulWidget { const ChatWebViewPage({ Key? key, @required this.title, @required this.url, + // this.cookieManager }) : super(key: key); final String? title; final String? url; + // final CookieManager? cookieManager; @override _ChatWebViewPageState createState() => _ChatWebViewPageState(); @@ -21,111 +29,66 @@ class ChatWebViewPage extends StatefulWidget { class _ChatWebViewPageState extends State { final Completer _controller = - Completer(); + Completer(); + + @override + void initState() { + super.initState(); + if (Platform.isAndroid) { + WebView.platform = SurfaceAndroidWebView(); + } + } @override Widget build(BuildContext context) { - return FutureBuilder( - future: _controller.future, - builder: (context, snapshot) { - return WillPopScope( - onWillPop: () async { - if (snapshot.hasData) { - var canGoBack = await snapshot.data!.canGoBack(); - if (canGoBack) { - // 网页可以返回时,优先返回上一页 - await snapshot.data!.goBack(); - return Future.value(false); - } - } - return Future.value(true); - }, - child: Scaffold( - appBar: AppBar( - title: Text(widget.title!), - elevation: 0, - actions: [ - // Align( - // alignment: Alignment.centerRight, - // child: Container( - // padding: new EdgeInsets.only(right: 10), - // child: InkWell( - // onTap: () { - // BytedeskUtils.printLog('share'); - // // showShareSheet(context); - // }, - // child: Image.asset( - // // 'assets/images/weibo/icon_more.png', - // 'assets/images/weibo/video_detail_share.png', - // width: 23.0, - // height: 23.0, - // )))), - ], - ), - body: WebView( - initialUrl: widget.url, - javascriptMode: JavascriptMode.unrestricted, - onWebViewCreated: (WebViewController webViewController) { - _controller.complete(webViewController); - }, - )), + return Scaffold( + appBar: AppBar( + title: Text(widget.title!), + elevation: 0, + actions: [ + // NavigationControls(_controller.future), + // SampleMenu(_controller.future, widget.cookieManager), + ], + ), + body: WebView( + initialUrl: widget.url, + javascriptMode: JavascriptMode.unrestricted, + onWebViewCreated: (WebViewController webViewController) { + _controller.complete(webViewController); + }, + onProgress: (int progress) { + print('WebView is loading (progress : $progress%)'); + }, + javascriptChannels: { + _toasterJavascriptChannel(context), + }, + navigationDelegate: (NavigationRequest request) { + // if (request.url.startsWith('')) { + // print('blocking navigation to $request}'); + // return NavigationDecision.prevent; + // } + print('allowing navigation to $request'); + return NavigationDecision.navigate; + }, + onPageStarted: (String url) { + print('Page started loading: $url'); + }, + onPageFinished: (String url) { + print('Page finished loading: $url'); + }, + gestureNavigationEnabled: true, + // geolocationEnabled: false,//support geolocation or not + )); + } + + JavascriptChannel _toasterJavascriptChannel(BuildContext context) { + return JavascriptChannel( + name: 'Toaster', + onMessageReceived: (JavascriptMessage message) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(message.message)), ); }); } - // void showShareSheet(BuildContext context) { - // showFLBottomSheet( - // context: context, - // builder: (BuildContext context) { - // return FLCupertinoOperationSheet( - // sheetStyle: FLCupertinoActionSheetStyle.filled, - // cancelButton: CupertinoActionSheetAction( - // child: Text( - // '取消', - // ), - // isDefaultAction: true, - // onPressed: () { - // Navigator.pop(context, 'cancel'); - // }, - // ), - // header: Container( - // padding: EdgeInsets.symmetric(vertical: 20, horizontal: 30), - // child: Text('分享', style: TextStyle(fontSize: 18)), - // ), - // itemList: [ - // [ - // FLCupertinoOperationSheetItem( - // imagePath: 'assets/images/circle/weibo.png', - // title: '复制链接', // TODO - // onPressed: () { - // Navigator.pop(context, 'weibo'); - // }, - // ), - // FLCupertinoOperationSheetItem( - // imagePath: 'assets/images/circle/weibo.png', - // title: '浏览器打开', // TODO - // onPressed: () { - // Navigator.pop(context, 'weibo'); - // }, - // ), - // FLCupertinoOperationSheetItem( - // imagePath: 'assets/images/circle/weibo.png', - // title: '刷新', // TODO - // onPressed: () { - // Navigator.pop(context, 'weibo'); - // }, - // ), - // ], - // ], - // ); - // }).then((value) { - // // - // BytedeskUtils.printLog('share $value'); - // if (value == 'wechat') { - // // TODO: 分享到微信 - // } else if (value == 'weibo') { - // // TODO: 分享到微博 - // } - // }); - // } } diff --git a/bytedesk_kefu/lib/ui/chat/widget/message_widget.dart b/bytedesk_kefu/lib/ui/chat/widget/message_widget.dart index 7dec9da..a1b4432 100755 --- a/bytedesk_kefu/lib/ui/chat/widget/message_widget.dart +++ b/bytedesk_kefu/lib/ui/chat/widget/message_widget.dart @@ -12,7 +12,7 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:fluttertoast/fluttertoast.dart'; -import 'package:flutter_html/flutter_html.dart'; +import 'package:bytedesk_kefu/vendors/flutter_html/flutter_html.dart'; import 'package:bytedesk_kefu/util/bytedesk_utils.dart'; // 系统消息居中显示 diff --git a/bytedesk_kefu/lib/ui/faq/page/help_article_detail_page.dart b/bytedesk_kefu/lib/ui/faq/page/help_article_detail_page.dart index c09d42e..06f1e4f 100755 --- a/bytedesk_kefu/lib/ui/faq/page/help_article_detail_page.dart +++ b/bytedesk_kefu/lib/ui/faq/page/help_article_detail_page.dart @@ -2,7 +2,6 @@ import 'package:bytedesk_kefu/blocs/help_bloc/bloc.dart'; import 'package:bytedesk_kefu/model/helpArticle.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -// import 'package:flutter_html/flutter_html.dart'; class HelpArticleDetailPage extends StatefulWidget { final HelpArticle? helpArticle; diff --git a/bytedesk_kefu/lib/vendors/emoji_picker_flutter/src/category_icons.dart b/bytedesk_kefu/lib/vendors/emoji_picker_flutter/src/category_icons.dart index 3096680..127a4d5 100644 --- a/bytedesk_kefu/lib/vendors/emoji_picker_flutter/src/category_icons.dart +++ b/bytedesk_kefu/lib/vendors/emoji_picker_flutter/src/category_icons.dart @@ -1,4 +1,4 @@ -import './category_icon.dart'; +import '../src/category_icon.dart'; import 'package:flutter/material.dart'; /// Class used to define all the [CategoryIcon] shown for each [Category] diff --git a/bytedesk_kefu/lib/vendors/emoji_picker_flutter/src/config.dart b/bytedesk_kefu/lib/vendors/emoji_picker_flutter/src/config.dart index de2e8be..aab1d2a 100644 --- a/bytedesk_kefu/lib/vendors/emoji_picker_flutter/src/config.dart +++ b/bytedesk_kefu/lib/vendors/emoji_picker_flutter/src/config.dart @@ -1,7 +1,7 @@ import 'dart:math'; -import './category_icons.dart'; -import './emoji_picker.dart'; +import '../src/category_icons.dart'; +import '../src/emoji_picker.dart'; import 'package:flutter/material.dart'; /// Default Widget if no recent is available diff --git a/bytedesk_kefu/lib/vendors/emoji_picker_flutter/src/default_emoji_picker_view.dart b/bytedesk_kefu/lib/vendors/emoji_picker_flutter/src/default_emoji_picker_view.dart index 5247ef8..f8bdf0b 100644 --- a/bytedesk_kefu/lib/vendors/emoji_picker_flutter/src/default_emoji_picker_view.dart +++ b/bytedesk_kefu/lib/vendors/emoji_picker_flutter/src/default_emoji_picker_view.dart @@ -1,8 +1,8 @@ import '../emoji_picker_flutter.dart'; -import './category_emoji.dart'; -import './emoji_picker_internal_utils.dart'; -import './emoji_skin_tones.dart'; -import './triangle_shape.dart'; +import '../src/category_emoji.dart'; +import '../src/emoji_picker_internal_utils.dart'; +import '../src/emoji_skin_tones.dart'; +import '../src/triangle_shape.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; diff --git a/bytedesk_kefu/lib/vendors/emoji_picker_flutter/src/emoji_lists.dart b/bytedesk_kefu/lib/vendors/emoji_picker_flutter/src/emoji_lists.dart index dac3fa3..eded00f 100644 --- a/bytedesk_kefu/lib/vendors/emoji_picker_flutter/src/emoji_lists.dart +++ b/bytedesk_kefu/lib/vendors/emoji_picker_flutter/src/emoji_lists.dart @@ -739,6 +739,7 @@ final Map animals = Map.fromIterables([ 'Fox Face', 'Bear Face', 'Panda Face', + 'Koala Face', 'Tiger Face', 'Lion Face', 'Cow Face', @@ -787,7 +788,6 @@ final Map animals = Map.fromIterables([ 'Chipmunk', 'Hedgehog', 'Bat', - 'Koala', 'Kangaroo', 'Badger', 'Paw Prints', diff --git a/bytedesk_kefu/lib/vendors/emoji_picker_flutter/src/emoji_picker.dart b/bytedesk_kefu/lib/vendors/emoji_picker_flutter/src/emoji_picker.dart index 33e0c79..5b0af55 100644 --- a/bytedesk_kefu/lib/vendors/emoji_picker_flutter/src/emoji_picker.dart +++ b/bytedesk_kefu/lib/vendors/emoji_picker_flutter/src/emoji_picker.dart @@ -1,10 +1,10 @@ -import './category_emoji.dart'; -import './config.dart'; -import './default_emoji_picker_view.dart'; -import './emoji.dart'; -import './emoji_picker_internal_utils.dart'; -import './emoji_view_state.dart'; -import './recent_emoji.dart'; +import '../src/category_emoji.dart'; +import '../src/config.dart'; +import '../src/default_emoji_picker_view.dart'; +import '../src/emoji.dart'; +import '../src/emoji_picker_internal_utils.dart'; +import '../src/emoji_view_state.dart'; +import '../src/recent_emoji.dart'; import 'package:flutter/material.dart'; /// All the possible categories that [Emoji] can be put into @@ -98,7 +98,8 @@ class EmojiPicker extends StatefulWidget { /// EmojiPicker for flutter const EmojiPicker({ Key? key, - required this.onEmojiSelected, + this.textEditingController, + this.onEmojiSelected, this.onBackspacePressed, this.config = const Config(), this.customWidget, @@ -107,8 +108,13 @@ class EmojiPicker extends StatefulWidget { /// Custom widget final EmojiViewBuilder? customWidget; + /// If you provide the [TextEditingController] that is linked to a + /// [TextField] this widget handles inserting and deleting for you + /// automatically. + final TextEditingController? textEditingController; + /// The function called when the emoji is selected - final OnEmojiSelected onEmojiSelected; + final OnEmojiSelected? onEmojiSelected; /// The function called when backspace button is pressed final OnBackspacePressed? onBackspacePressed; @@ -161,7 +167,7 @@ class EmojiPickerState extends State { if (!_loaded) { // Load emojis _updateEmojiFuture.then( - (value) => WidgetsBinding.instance!.addPostFrameCallback((_) { + (value) => WidgetsBinding.instance!.addPostFrameCallback((_) { if (!mounted) return; setState(() { _loaded = true; @@ -183,7 +189,9 @@ class EmojiPickerState extends State { var state = EmojiViewState( _categoryEmoji, _getOnEmojiListener(), - widget.onBackspacePressed, + widget.onBackspacePressed == null && widget.textEditingController == null + ? null + : _onBackspacePressed, ); // Build @@ -192,6 +200,30 @@ class EmojiPickerState extends State { : widget.customWidget!(widget.config, state); } + void _onBackspacePressed() { + if (widget.textEditingController != null) { + final controller = widget.textEditingController!; + + final selection = controller.value.selection; + final text = controller.value.text; + final cursorPosition = controller.selection.base.offset; + + if (cursorPosition < 0) { + widget.onBackspacePressed?.call(); + return; + } + + final newTextBeforeCursor = + selection.textBefore(text).characters.skipLast(1).toString(); + controller + ..text = newTextBeforeCursor + selection.textAfter(text) + ..selection = TextSelection.fromPosition( + TextPosition(offset: newTextBeforeCursor.length)); + } + + widget.onBackspacePressed?.call(); + } + // Add recent emoji handling to tap listener OnEmojiSelected _getOnEmojiListener() { return (category, emoji) { @@ -199,15 +231,40 @@ class EmojiPickerState extends State { _emojiPickerInternalUtils .addEmojiToRecentlyUsed(emoji: emoji, config: widget.config) .then((newRecentEmoji) => { - _recentEmoji = newRecentEmoji, - if (category != Category.RECENT && mounted) - setState(() { - // rebuild to update recent emoji tab - // when it is not current tab - }) - }); + _recentEmoji = newRecentEmoji, + if (category != Category.RECENT && mounted) + setState(() { + // rebuild to update recent emoji tab + // when it is not current tab + }) + }); } - widget.onEmojiSelected(category, emoji); + + if (widget.textEditingController != null) { + // based on https://stackoverflow.com/a/60058972/10975692 + final controller = widget.textEditingController!; + final text = controller.text; + final selection = controller.selection; + final cursorPosition = controller.selection.base.offset; + + if (cursorPosition < 0) { + controller.text += emoji.emoji; + widget.onEmojiSelected?.call(category, emoji); + return; + } + + final newText = + text.replaceRange(selection.start, selection.end, emoji.emoji); + final emojiLength = emoji.emoji.length; + controller + ..text = newText + ..selection = selection.copyWith( + baseOffset: selection.start + emojiLength, + extentOffset: selection.start + emojiLength, + ); + } + + widget.onEmojiSelected?.call(category, emoji); }; } @@ -221,7 +278,7 @@ class EmojiPickerState extends State { } final availableCategoryEmoji = - await _emojiPickerInternalUtils.getAvailableCategoryEmoji(); + await _emojiPickerInternalUtils.getAvailableCategoryEmoji(); availableCategoryEmoji.forEach((category, emojis) async { _categoryEmoji.add( diff --git a/bytedesk_kefu/lib/vendors/emoji_picker_flutter/src/emoji_picker_builder.dart b/bytedesk_kefu/lib/vendors/emoji_picker_flutter/src/emoji_picker_builder.dart index 2018b31..96f9a5b 100644 --- a/bytedesk_kefu/lib/vendors/emoji_picker_flutter/src/emoji_picker_builder.dart +++ b/bytedesk_kefu/lib/vendors/emoji_picker_flutter/src/emoji_picker_builder.dart @@ -1,5 +1,5 @@ -import './config.dart'; -import './emoji_view_state.dart'; +import '../src/config.dart'; +import '../src/emoji_view_state.dart'; import 'package:flutter/material.dart'; /// Template class for custom implementation diff --git a/bytedesk_kefu/lib/vendors/emoji_picker_flutter/src/emoji_picker_internal_utils.dart b/bytedesk_kefu/lib/vendors/emoji_picker_flutter/src/emoji_picker_internal_utils.dart index 370bc3b..b65080d 100644 --- a/bytedesk_kefu/lib/vendors/emoji_picker_flutter/src/emoji_picker_internal_utils.dart +++ b/bytedesk_kefu/lib/vendors/emoji_picker_flutter/src/emoji_picker_internal_utils.dart @@ -3,7 +3,7 @@ import 'dart:io'; import 'dart:math'; import '../emoji_picker_flutter.dart'; -import './emoji_skin_tones.dart'; +import '../src/emoji_skin_tones.dart'; import 'package:flutter/services.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'emoji_lists.dart' as emoji_list; diff --git a/bytedesk_kefu/lib/vendors/emoji_picker_flutter/src/emoji_picker_utils.dart b/bytedesk_kefu/lib/vendors/emoji_picker_flutter/src/emoji_picker_utils.dart index 59d689d..614a146 100644 --- a/bytedesk_kefu/lib/vendors/emoji_picker_flutter/src/emoji_picker_utils.dart +++ b/bytedesk_kefu/lib/vendors/emoji_picker_flutter/src/emoji_picker_utils.dart @@ -1,5 +1,5 @@ import '../emoji_picker_flutter.dart'; -import './recent_emoji.dart'; +import '../src/recent_emoji.dart'; import 'package:flutter/material.dart'; import 'emoji_picker_internal_utils.dart'; diff --git a/bytedesk_kefu/lib/vendors/emoji_picker_flutter/src/emoji_view_state.dart b/bytedesk_kefu/lib/vendors/emoji_picker_flutter/src/emoji_view_state.dart index f58aee8..bac6b60 100644 --- a/bytedesk_kefu/lib/vendors/emoji_picker_flutter/src/emoji_view_state.dart +++ b/bytedesk_kefu/lib/vendors/emoji_picker_flutter/src/emoji_view_state.dart @@ -1,5 +1,5 @@ import '../emoji_picker_flutter.dart'; -import './category_emoji.dart'; +import '../src/category_emoji.dart'; /// State that holds current emoji data class EmojiViewState { diff --git a/bytedesk_kefu/lib/vendors/emoji_picker_flutter/src/recent_emoji.dart b/bytedesk_kefu/lib/vendors/emoji_picker_flutter/src/recent_emoji.dart index cd02388..fb195b9 100644 --- a/bytedesk_kefu/lib/vendors/emoji_picker_flutter/src/recent_emoji.dart +++ b/bytedesk_kefu/lib/vendors/emoji_picker_flutter/src/recent_emoji.dart @@ -1,4 +1,4 @@ -import './emoji.dart'; +import '../src/emoji.dart'; /// Class that holds an recent emoji /// Recent Emoji has an instance of the emoji diff --git a/bytedesk_kefu/lib/vendors/flutter_html/custom_render.dart b/bytedesk_kefu/lib/vendors/flutter_html/custom_render.dart new file mode 100644 index 0000000..ee11483 --- /dev/null +++ b/bytedesk_kefu/lib/vendors/flutter_html/custom_render.dart @@ -0,0 +1,635 @@ +import 'package:collection/collection.dart'; + +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import './flutter_html.dart'; +import './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 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 schemas: const ["https", "http"], + List? 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 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, 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 (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("\u0020", + 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( + 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? 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? 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 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 imageInfo, bool 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(ImageConfiguration()).removeListener(listener!); + } + }, onError: (object, stacktrace) { + if (!completer.isCompleted) { + completer.completeError(object); + image.image.resolve(ImageConfiguration()).removeListener(listener!); + } + }); + + image.image.resolve(ImageConfiguration()).addListener(listener); + } + final attributes = + context.tree.element!.attributes.cast(); + final widget = FutureBuilder( + future: completer.future, + initialData: context.parser.cachedImageSizes[src], + builder: (BuildContext buildContext, AsyncSnapshot 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? 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?.parent?.localName != "th" && + tree.element?.parent?.localName != "td" && + tree.element?.localName != "html" && + tree.element?.localName != "body") + TextSpan(text: "\n"), + ]) + .toList(), + )); + +final Map defaultRenders = { + blockElementMatcher(): blockElementRender(), + listElementMatcher(): listElementRender(), + textContentElementMatcher(): textContentElementRender(), + dataUriMatcher(): base64ImageRender(), + assetUriMatcher(): assetImageRender(), + networkSourceMatcher(): networkImageRender(), + 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, + ), + ), + ); + } +} + +final _dataUriFormat = RegExp( + "^(?data):(?image\/[\\w\+\-\.]+)(?;base64)?\,(?.*)"); + +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; + } +} + +String? _src(Map attributes) { + return attributes["src"]; +} + +String? _alt(Map attributes) { + return attributes["alt"]; +} + +double? _height(Map attributes) { + final heightString = attributes["height"]; + return heightString == null + ? heightString as double? + : double.tryParse(heightString); +} + +double? _width(Map attributes) { + final widthString = attributes["width"]; + return widthString == null + ? widthString as double? + : double.tryParse(widthString); +} + +double _aspectRatio( + Map attributes, AsyncSnapshot 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 => + this.clamp(EdgeInsets.zero, const EdgeInsets.all(double.infinity)); +} diff --git a/bytedesk_kefu/lib/vendors/flutter_html/flutter_html.dart b/bytedesk_kefu/lib/vendors/flutter_html/flutter_html.dart new file mode 100644 index 0000000..79dfa84 --- /dev/null +++ b/bytedesk_kefu/lib/vendors/flutter_html/flutter_html.dart @@ -0,0 +1,371 @@ +library flutter_html; + +import 'package:flutter/material.dart'; +import './custom_render.dart'; +import './html_parser.dart'; +import './src/html_elements.dart'; +import './style.dart'; +import 'package:html/dom.dart' as dom; + +export './custom_render.dart'; +//export render context api +export './html_parser.dart'; +//export render context api +export './html_parser.dart'; +//export src for advanced custom render uses (e.g. casting context.tree) +export './src/anchor.dart'; +export './src/interactable_element.dart'; +export './src/layout_element.dart'; +export './src/replaced_element.dart'; +export './src/styled_element.dart'; +//export style api +export './style.dart'; + +class Html extends StatefulWidget { + /// The `Html` widget takes HTML as input and displays a RichText + /// tree of the parsed HTML content. + /// + /// **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 (``) + /// is tapped. + /// **customRender** This function allows you to return your own widgets + /// for existing or custom HTML tags. + /// See [its wiki page](https://github.com/Sub6Resources/flutter_html/wiki/All-About-customRender) for more info. + /// + /// **onImageError** This is called whenever an image fails to load or + /// display on the page. + /// + /// **shrinkWrap** This makes the Html widget take up only the width it + /// needs and no more. + /// + /// **onImageTap** This is called whenever an image is tapped. + /// + /// **tagsList** Tag names in this array will be the only tags rendered. By default all supported HTML tags 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. + Html({ + Key? 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(), + super(key: key); + + Html.fromDom({ + Key? 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), + this.documentElement = document!.documentElement, + _anchorKey = anchorKey ?? GlobalKey(), + super(key: key); + + Html.fromElement({ + Key? 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(), + super(key: key); + + /// 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 tagsList; + + /// Either return a custom widget for specific node types or return null to + /// fallback to the default rendering. + final Map customRenders; + + /// An API that allows you to override the default style for any HTML element + final Map style; + + static List get tags => new List.from(STYLED_ELEMENTS) + ..addAll(INTERACTABLE_ELEMENTS) + ..addAll(REPLACED_ELEMENTS) + ..addAll(LAYOUT_ELEMENTS) + ..addAll(TABLE_CELL_ELEMENTS) + ..addAll(TABLE_DEFINITION_ELEMENTS) + ..addAll(EXTERNAL_ELEMENTS); + + @override + State createState() => _HtmlState(); +} + +class _HtmlState extends State { + 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 Container( + width: widget.shrinkWrap ? null : MediaQuery.of(context).size.width, + child: 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(defaultRenders), + 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 (``) + /// 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({ + Key? 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(), + super(key: key); + + SelectableHtml.fromDom({ + Key? 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), + this.documentElement = document!.documentElement, + _anchorKey = anchorKey ?? GlobalKey(), + super(key: key); + + SelectableHtml.fromElement({ + Key? 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(), + super(key: key); + + /// 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 + /// flexible + 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 tagsList; + + /// An API that allows you to override the default style for any HTML element + final Map 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 customRenders; + + static List get tags => new List.from(SELECTABLE_ELEMENTS); + + @override + State createState() => _SelectableHtmlState(); +} + +class _SelectableHtmlState extends State { + 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 Container( + 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(defaultRenders), + tagsList: + widget.tagsList.isEmpty ? SelectableHtml.tags : widget.tagsList, + selectionControls: widget.selectionControls, + scrollPhysics: widget.scrollPhysics, + ), + ); + } +} diff --git a/bytedesk_kefu/lib/vendors/flutter_html/html_parser.dart b/bytedesk_kefu/lib/vendors/flutter_html/html_parser.dart new file mode 100644 index 0000000..e659012 --- /dev/null +++ b/bytedesk_kefu/lib/vendors/flutter_html/html_parser.dart @@ -0,0 +1,961 @@ +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 './flutter_html.dart'; +import './src/css_parser.dart'; +import './src/html_elements.dart'; +import './src/utils.dart'; +import 'package:html/dom.dart' as dom; +import 'package:html/parser.dart' as htmlparser; +import 'package:numerus/numerus.dart'; + +typedef OnTap = void Function( + String? url, + RenderContext context, + Map attributes, + dom.Element? element, +); +typedef OnCssParseError = String? Function( + String css, + List errors, +); + +class HtmlParser extends StatelessWidget { + final Key? key; + 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 style; + final Map customRenders; + final List tagsList; + final OnTap? internalOnAnchorTap; + final Html? root; + final TextSelectionControls? selectionControls; + final ScrollPhysics? scrollPhysics; + + final Map cachedImageSizes = {}; + + HtmlParser({ + required this.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, + }) : this.internalOnAnchorTap = onAnchorTap != null + ? onAnchorTap + : key != null + ? _handleAnchorTap(key, onLinkTap) + : onLinkTap, + super(key: key); + + @override + Widget build(BuildContext context) { + Map>> declarations = _getExternalCssDeclarations(htmlData.getElementsByTagName("style"), onCssParseError); + StyledElement lexedTree = lexDomTree( + htmlData, + customRenders.keys.toList(), + tagsList, + context, + this, + ); + StyledElement? externalCssStyledTree; + if (declarations.isNotEmpty) { + externalCssStyledTree = _applyExternalCss(declarations, lexedTree); + } + StyledElement inlineStyledTree = _applyInlineStyles(externalCssStyledTree ?? lexedTree, onCssParseError); + StyledElement customStyledTree = _applyCustomStyles(style, inlineStyledTree); + StyledElement cascadedStyledTree = _cascadeStyles(style, customStyledTree); + StyledElement cleanedTree = cleanTree(cascadedStyledTree); + InlineSpan parsedTree = parseTree( + RenderContext( + buildContext: context, + parser: this, + tree: cleanedTree, + style: cleanedTree.style, + ), + cleanedTree, + ); + + // This is the final scaling that assumes any other StyledText instances are + // 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: cleanedTree.style, + ), + selectionControls: selectionControls, + scrollPhysics: scrollPhysics, + ); + } + return StyledText( + textSpan: parsedTree, + style: cleanedTree.style, + textScaleFactor: MediaQuery.of(context).textScaleFactor, + renderContext: RenderContext( + buildContext: context, + parser: this, + tree: cleanedTree, + style: cleanedTree.style, + ), + ); + } + + /// [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 customRenderMatchers, + List tagsList, + BuildContext context, + HtmlParser parser, + ) { + StyledElement tree = StyledElement( + name: "[Tree Root]", + children: [], + node: html, + style: Style.fromTextStyle(Theme.of(context).textTheme.bodyText2!), + ); + + html.nodes.forEach((node) { + 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 customRenderMatchers, + List tagsList, + BuildContext context, + HtmlParser parser, + ) { + List children = []; + + node.nodes.forEach((childNode) { + children.add(_recursiveLexer( + childNode, + customRenderMatchers, + tagsList, + context, + parser, + )); + }); + + //TODO(Sub6Resources): There's probably a more efficient way to look this up. + if (node is dom.Element) { + if (!tagsList.contains(node.localName)) { + return EmptyContentElement(); + } + if (STYLED_ELEMENTS.contains(node.localName)) { + return parseStyledElement(node, children); + } else if (INTERACTABLE_ELEMENTS.contains(node.localName)) { + return parseInteractableElement(node, children); + } else if (REPLACED_ELEMENTS.contains(node.localName)) { + return parseReplacedElement(node, children); + } else if (LAYOUT_ELEMENTS.contains(node.localName)) { + return parseLayoutElement(node, children); + } else if (TABLE_CELL_ELEMENTS.contains(node.localName)) { + return parseTableCellElement(node, children); + } else if (TABLE_DEFINITION_ELEMENTS.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.bodyText2!), + ), + )) { + 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>> _getExternalCssDeclarations(List 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>> declarations, StyledElement tree) { + declarations.forEach((key, style) { + try { + if (tree.matchesSelector(key)) { + tree.style = tree.style.merge(declarationsToStyle(style)); + } + } catch (_) {} + }); + + tree.children.forEach((e) => _applyExternalCss(declarations, e)); + + 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); + } + } + + tree.children.forEach((e) => _applyInlineStyles(e, 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 style, StyledElement tree) { + style.forEach((key, style) { + try { + if (tree.matchesSelector(key)) { + tree.style = tree.style.merge(style); + } + } catch (_) {} + }); + tree.children.forEach((e) => _applyCustomStyles(style, e)); + + 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 style, StyledElement tree) { + tree.children.forEach((child) { + child.style = tree.style.copyOnlyInherited(child.style); + _cascadeStyles(style, child); + }); + + return tree; + } + + /// [cleanTree] 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 cleanTree(StyledElement tree) { + tree = _processInternalWhitespace(tree); + tree = _processInlineWhitespace(tree); + tree = _removeEmptyElements(tree); + tree = _processListCharacters(tree); + tree = _processBeforesAndAfters(tree); + tree = _collapseMargins(tree); + tree = _processFontSize(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)) { + 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 WidgetSpan( + child: ContainerSpan( + newContext: newContext, + style: tree.style, + shrinkWrap: newContext.parser.shrinkWrap, + child: customRenders[entry]!.widget!.call(newContext, buildChildren), + ), + ); + } + } + return WidgetSpan(child: Container(height: 0, width: 0)); + } + + static OnTap _handleAnchorTap(Key key, OnTap? onLinkTap) => + (String? url, RenderContext context, Map attributes, dom.Element? 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 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
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(' '); + } + } + + tree.children.forEach((e) => _processInlineWhitespaceRecursive(e, 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,}"), " "); + } + + /// [processListCharacters] adds list characters to the front of all list items. + /// + /// The function uses the [_processListCharactersRecursive] function to do most of its work. + static StyledElement _processListCharacters(StyledElement tree) { + final olStack = ListQueue(); + tree = _processListCharactersRecursive(tree, olStack); + return tree; + } + + /// [_processListCharactersRecursive] uses a Stack of integers to properly number and + /// bullet all list items according to the [ListStyleType] they have been given. + static StyledElement _processListCharactersRecursive( + StyledElement tree, ListQueue olStack) { + if (tree.style.listStylePosition == null) { + tree.style.listStylePosition = ListStylePosition.OUTSIDE; + } + if (tree.name == 'ol' && tree.style.listStyleType != null && tree.style.listStyleType!.type == "marker") { + switch (tree.style.listStyleType!) { + case ListStyleType.LOWER_LATIN: + case ListStyleType.LOWER_ALPHA: + case ListStyleType.UPPER_LATIN: + case ListStyleType.UPPER_ALPHA: + olStack.add(Context('a')); + if ((tree.attributes['start'] != null ? int.tryParse(tree.attributes['start']!) : null) != null) { + var start = int.tryParse(tree.attributes['start']!) ?? 1; + var x = 1; + while (x < start) { + olStack.last.data = olStack.last.data.toString().nextLetter(); + x++; + } + } + break; + default: + olStack.add(Context((tree.attributes['start'] != null ? int.tryParse(tree.attributes['start'] ?? "") ?? 1 : 1) - 1)); + break; + } + } else if (tree.style.display == Display.LIST_ITEM && tree.style.listStyleType != null && tree.style.listStyleType!.type == "widget") { + tree.style.markerContent = tree.style.listStyleType!.widget!; + } else if (tree.style.display == Display.LIST_ITEM && tree.style.listStyleType != null && tree.style.listStyleType!.type == "image") { + tree.style.markerContent = Image.network(tree.style.listStyleType!.text); + } else if (tree.style.display == Display.LIST_ITEM && tree.style.listStyleType != null) { + String marker = ""; + switch (tree.style.listStyleType!) { + case ListStyleType.NONE: + break; + case ListStyleType.CIRCLE: + marker = '○'; + break; + case ListStyleType.SQUARE: + marker = '■'; + break; + case ListStyleType.DISC: + marker = '•'; + break; + case ListStyleType.DECIMAL: + if (olStack.isEmpty) { + olStack.add(Context((tree.attributes['start'] != null ? int.tryParse(tree.attributes['start'] ?? "") ?? 1 : 1) - 1)); + } + olStack.last.data += 1; + marker = '${olStack.last.data}.'; + break; + case ListStyleType.LOWER_LATIN: + case ListStyleType.LOWER_ALPHA: + if (olStack.isEmpty) { + olStack.add(Context('a')); + if ((tree.attributes['start'] != null ? int.tryParse(tree.attributes['start']!) : null) != null) { + var start = int.tryParse(tree.attributes['start']!) ?? 1; + var x = 1; + while (x < start) { + olStack.last.data = olStack.last.data.toString().nextLetter(); + x++; + } + } + } + marker = olStack.last.data.toString() + "."; + olStack.last.data = olStack.last.data.toString().nextLetter(); + break; + case ListStyleType.UPPER_LATIN: + case ListStyleType.UPPER_ALPHA: + if (olStack.isEmpty) { + olStack.add(Context('a')); + if ((tree.attributes['start'] != null ? int.tryParse(tree.attributes['start']!) : null) != null) { + var start = int.tryParse(tree.attributes['start']!) ?? 1; + var x = 1; + while (x < start) { + olStack.last.data = olStack.last.data.toString().nextLetter(); + x++; + } + } + } + marker = olStack.last.data.toString().toUpperCase() + "."; + olStack.last.data = olStack.last.data.toString().nextLetter(); + break; + case ListStyleType.LOWER_ROMAN: + if (olStack.isEmpty) { + olStack.add(Context((tree.attributes['start'] != null ? int.tryParse(tree.attributes['start'] ?? "") ?? 1 : 1) - 1)); + } + olStack.last.data += 1; + if (olStack.last.data <= 0) { + marker = '${olStack.last.data}.'; + } else { + marker = (olStack.last.data as int).toRomanNumeralString()!.toLowerCase() + "."; + } + break; + case ListStyleType.UPPER_ROMAN: + if (olStack.isEmpty) { + olStack.add(Context((tree.attributes['start'] != null ? int.tryParse(tree.attributes['start'] ?? "") ?? 1 : 1) - 1)); + } + olStack.last.data += 1; + if (olStack.last.data <= 0) { + marker = '${olStack.last.data}.'; + } else { + marker = (olStack.last.data as int).toRomanNumeralString()! + "."; + } + break; + } + tree.style.markerContent = Text( + marker, + textAlign: TextAlign.right, + style: tree.style.generateTextStyle(), + ); + } + + tree.children.forEach((e) => _processListCharactersRecursive(e, olStack)); + + if (tree.name == 'ol') { + olStack.removeLast(); + } + + return tree; + } + + /// [_processBeforesAndAfters] adds text content to the beginning and end of + /// the list of the trees children according to the `before` and `after` Style + /// properties. + static StyledElement _processBeforesAndAfters(StyledElement tree) { + if (tree.style.before != null) { + tree.children.insert( + 0, TextContentElement(text: tree.style.before, style: tree.style.copyWith(beforeAfterNull: true, display: Display.INLINE))); + } + if (tree.style.after != null) { + tree.children + .add(TextContentElement(text: tree.style.after, style: tree.style.copyWith(beforeAfterNull: true, display: Display.INLINE))); + } + + tree.children.forEach(_processBeforesAndAfters); + + return tree; + } + + /// [collapseMargins] follows the specifications at https://www.w3.org/TR/CSS21/box.html#collapsing-margins + /// for collapsing margins of block-level boxes. This prevents the doubling of margins between + /// boxes, and makes for a more correct rendering of the html content. + /// + /// Paraphrased from the CSS specification: + /// Margins are collapsed if both belong to vertically-adjacent box edges, i.e form one of the following pairs: + /// (1) Top margin of a box and top margin of its first in-flow child + /// (2) Bottom margin of a box and top margin of its next in-flow following sibling + /// (3) Bottom margin of a last in-flow child and bottom margin of its parent (if the parent's height is not explicit) + /// (4) Top and Bottom margins of a box with a height of zero or no in-flow children. + static StyledElement _collapseMargins(StyledElement tree) { + //Short circuit if we've reached a leaf of the tree + if (tree.children.isEmpty) { + // Handle case (4) from above. + if ((tree.style.height ?? 0) == 0) { + tree.style.margin = EdgeInsets.zero; + } + return tree; + } + + //Collapsing should be depth-first. + tree.children.forEach(_collapseMargins); + + //The root boxes do not collapse. + if (tree.name == '[Tree Root]' || tree.name == 'html') { + return tree; + } + + // Handle case (1) from above. + // Top margins cannot collapse if the element has padding + if ((tree.style.padding?.top ?? 0) == 0) { + final parentTop = tree.style.margin?.top ?? 0; + final firstChildTop = tree.children.first.style.margin?.top ?? 0; + final newOuterMarginTop = max(parentTop, firstChildTop); + + // Set the parent's margin + if (tree.style.margin == null) { + tree.style.margin = EdgeInsets.only(top: newOuterMarginTop); + } else { + tree.style.margin = tree.style.margin!.copyWith(top: newOuterMarginTop); + } + + // And remove the child's margin + if (tree.children.first.style.margin == null) { + tree.children.first.style.margin = EdgeInsets.zero; + } else { + tree.children.first.style.margin = + tree.children.first.style.margin!.copyWith(top: 0); + } + } + + // Handle case (3) from above. + // Bottom margins cannot collapse if the element has padding + if ((tree.style.padding?.bottom ?? 0) == 0) { + final parentBottom = tree.style.margin?.bottom ?? 0; + final lastChildBottom = tree.children.last.style.margin?.bottom ?? 0; + final newOuterMarginBottom = max(parentBottom, lastChildBottom); + + // Set the parent's margin + if (tree.style.margin == null) { + tree.style.margin = EdgeInsets.only(bottom: newOuterMarginBottom); + } else { + tree.style.margin = + tree.style.margin!.copyWith(bottom: newOuterMarginBottom); + } + + // And remove the child's margin + if (tree.children.last.style.margin == null) { + tree.children.last.style.margin = EdgeInsets.zero; + } else { + tree.children.last.style.margin = + tree.children.last.style.margin!.copyWith(bottom: 0); + } + } + + // Handle case (2) from above. + if (tree.children.length > 1) { + for (int i = 1; i < tree.children.length; i++) { + final previousSiblingBottom = + tree.children[i - 1].style.margin?.bottom ?? 0; + final thisTop = tree.children[i].style.margin?.top ?? 0; + final newInternalMargin = max(previousSiblingBottom, thisTop) / 2; + + if (tree.children[i - 1].style.margin == null) { + tree.children[i - 1].style.margin = + EdgeInsets.only(bottom: newInternalMargin); + } else { + tree.children[i - 1].style.margin = tree.children[i - 1].style.margin! + .copyWith(bottom: newInternalMargin); + } + + if (tree.children[i].style.margin == null) { + tree.children[i].style.margin = + EdgeInsets.only(top: newInternalMargin); + } else { + tree.children[i].style.margin = + tree.children[i].style.margin!.copyWith(top: newInternalMargin); + } + } + } + + return tree; + } + + /// [removeEmptyElements] recursively removes empty elements. + /// + /// An empty element is any [EmptyContentElement], any empty [TextContentElement], + /// or any block-level [TextContentElement] that contains only whitespace and doesn't follow + /// a block element or a line break. + static StyledElement _removeEmptyElements(StyledElement tree) { + List toRemove = []; + bool lastChildBlock = true; + tree.children.forEachIndexed((index, child) { + if (child is EmptyContentElement || child is EmptyLayoutElement) { + toRemove.add(child); + } else if (child is TextContentElement + && ((tree.name == "body" + && (index == 0 + || index + 1 == tree.children.length + || tree.children[index - 1].style.display == Display.BLOCK + || tree.children[index + 1].style.display == Display.BLOCK)) + || tree.name == "ul") + && child.text!.replaceAll(' ', '').isEmpty) { + toRemove.add(child); + } else if (child is TextContentElement + && child.text!.isEmpty + && child.style.whiteSpace != WhiteSpace.PRE) { + toRemove.add(child); + } else if (child is TextContentElement && + child.style.whiteSpace != WhiteSpace.PRE && + tree.style.display == Display.BLOCK && + child.text!.isEmpty && + lastChildBlock) { + toRemove.add(child); + } else if (child.style.display == Display.NONE) { + toRemove.add(child); + } else { + _removeEmptyElements(child); + } + + // This is used above to check if the previous element is a block element or a line break. + lastChildBlock = (child.style.display == Display.BLOCK || + child.style.display == Display.LIST_ITEM || + (child is TextContentElement && child.text == '\n')); + }); + tree.children.removeWhere((element) => toRemove.contains(element)); + + return tree; + } + + /// [_processFontSize] changes percent-based font sizes (negative numbers in this implementation) + /// to pixel-based font sizes. + static StyledElement _processFontSize(StyledElement tree) { + double? parentFontSize = tree.style.fontSize?.size ?? FontSize.medium.size; + + tree.children.forEach((child) { + if ((child.style.fontSize?.size ?? parentFontSize)! < 0) { + child.style.fontSize = + FontSize(parentFontSize! * -child.style.fontSize!.size!); + } + + _processFontSize(child); + }); + return tree; + } +} + +/// The [RenderContext] is available when parsing the tree. It contains information +/// about the [BuildContext] of the `Html` widget, contains the configuration available +/// in the [HtmlParser], and contains information about the [Style] of the current +/// tree root. +class RenderContext { + final BuildContext buildContext; + 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, + }); +} + +/// A [ContainerSpan] is a widget with an [InlineSpan] child or children. +/// +/// A [ContainerSpan] can have a border, background color, height, width, padding, and margin +/// and can represent either an INLINE or BLOCK-level element. +class ContainerSpan extends StatelessWidget { + final AnchorKey? key; + final Widget? child; + final List? children; + final Style style; + final RenderContext newContext; + final bool shrinkWrap; + + ContainerSpan({ + this.key, + this.child, + this.children, + required this.style, + required this.newContext, + this.shrinkWrap = false, + }): super(key: key); + + @override + Widget build(BuildContext _) { + return Container( + decoration: BoxDecoration( + border: style.border, + color: style.backgroundColor, + ), + height: style.height, + width: style.width, + padding: style.padding?.nonNegative, + margin: style.margin?.nonNegative, + alignment: shrinkWrap ? null : style.alignment, + child: child ?? + StyledText( + textSpan: TextSpan( + style: newContext.style.generateTextStyle(), + children: children, + ), + style: newContext.style, + renderContext: newContext, + ), + ); + } +} + +class StyledText extends StatelessWidget { + final InlineSpan textSpan; + final Style style; + final double textScaleFactor; + final RenderContext renderContext; + final AnchorKey? key; + final bool _selectable; + final TextSelectionControls? selectionControls; + final ScrollPhysics? scrollPhysics; + + const StyledText({ + required this.textSpan, + required this.style, + this.textScaleFactor = 1.0, + required this.renderContext, + this.key, + this.selectionControls, + this.scrollPhysics, + }) : _selectable = false, + super(key: key); + + const StyledText.selectable({ + required TextSpan textSpan, + required this.style, + this.textScaleFactor = 1.0, + required this.renderContext, + this.key, + this.selectionControls, + this.scrollPhysics, + }) : textSpan = textSpan, + _selectable = true, + super(key: key); + + @override + Widget build(BuildContext context) { + if (_selectable) { + return SelectableText.rich( + textSpan as TextSpan, + style: style.generateTextStyle(), + textAlign: style.textAlign, + textDirection: style.direction, + textScaleFactor: textScaleFactor, + maxLines: style.maxLines, + selectionControls: selectionControls, + scrollPhysics: scrollPhysics, + ); + } + return SizedBox( + width: consumeExpandedBlock(style.display, renderContext), + child: Text.rich( + textSpan, + style: style.generateTextStyle(), + textAlign: style.textAlign, + textDirection: style.direction, + textScaleFactor: textScaleFactor, + maxLines: style.maxLines, + overflow: style.textOverflow, + ), + ); + } + + double? consumeExpandedBlock(Display? display, RenderContext context) { + if ((display == Display.BLOCK || display == Display.LIST_ITEM) && !renderContext.parser.shrinkWrap) { + return double.infinity; + } + return null; + } +} + +extension IterateLetters on String { + String nextLetter() { + String s = this.toLowerCase(); + if (s == "z") { + return String.fromCharCode(s.codeUnitAt(0) - 25) + String.fromCharCode(s.codeUnitAt(0) - 25); // AA or aa + } else { + var lastChar = s.substring(s.length - 1); + var sub = s.substring(0, s.length - 1); + if (lastChar == "z") { + // If a string of length > 1 ends in Z/z, + // increment the string (excluding the last Z/z) recursively, + // and append A/a (depending on casing) to it + return sub.nextLetter() + 'a'; + } else { + // (take till last char) append with (increment last char) + return sub + String.fromCharCode(lastChar.codeUnitAt(0) + 1); + } + } + } +} diff --git a/bytedesk_kefu/lib/vendors/flutter_html/src/anchor.dart b/bytedesk_kefu/lib/vendors/flutter_html/src/anchor.dart new file mode 100644 index 0000000..f232331 --- /dev/null +++ b/bytedesk_kefu/lib/vendors/flutter_html/src/anchor.dart @@ -0,0 +1,42 @@ +import 'package:flutter/widgets.dart'; +import './styled_element.dart'; + +class AnchorKey extends GlobalKey { + static final Set _registry = {}; + + final Key parentKey; + final String id; + + const AnchorKey._(this.parentKey, this.id) : super.constructor(); + + static AnchorKey? of(Key? parentKey, StyledElement? id) { + final key = forId(parentKey, id?.elementId); + if (key == null || _registry.contains(key)) { + // Invalid id or already created a key with this id: silently ignore + return null; + } + _registry.add(key); + return key; + } + + static AnchorKey? forId(Key? parentKey, String? id) { + if (parentKey == null || id == null || id.isEmpty || id == "[[No ID]]") { + return null; + } + + return AnchorKey._(parentKey, id); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is AnchorKey && runtimeType == other.runtimeType && parentKey == other.parentKey && id == other.id; + + @override + int get hashCode => parentKey.hashCode ^ id.hashCode; + + @override + String toString() { + return 'AnchorKey{parentKey: $parentKey, id: #$id}'; + } +} diff --git a/bytedesk_kefu/lib/vendors/flutter_html/src/css_parser.dart b/bytedesk_kefu/lib/vendors/flutter_html/src/css_parser.dart new file mode 100644 index 0000000..6f84208 --- /dev/null +++ b/bytedesk_kefu/lib/vendors/flutter_html/src/css_parser.dart @@ -0,0 +1,981 @@ +import 'dart:ui'; + +import 'package:collection/collection.dart'; +import 'package:csslib/visitor.dart' as css; +import 'package:csslib/parser.dart' as cssparser; +import 'package:flutter/material.dart'; +import '../flutter_html.dart'; +import './utils.dart'; + +Style declarationsToStyle(Map> declarations) { + Style style = new Style(); + declarations.forEach((property, value) { + if (value.isNotEmpty) { + switch (property) { + case 'background-color': + style.backgroundColor = ExpressionMapping.expressionToColor(value.first) ?? style.backgroundColor; + break; + case 'border': + List? borderWidths = value.whereType().toList(); + /// List might include other values than the ones we want for [BorderSide.width], so make sure to remove those before passing it to [ExpressionMapping] + borderWidths.removeWhere((element) => element == null || (element.text != "thin" + && element.text != "medium" && element.text != "thick" + && !(element is css.LengthTerm) && !(element is css.PercentageTerm) + && !(element is css.EmTerm) && !(element is css.RemTerm) + && !(element is css.NumberTerm)) + ); + List? borderColors = value.where((element) => ExpressionMapping.expressionToColor(element) != null).toList(); + List? potentialStyles = value.whereType().toList(); + /// Currently doesn't matter, as Flutter only supports "solid" or "none", but may support more in the future. + List possibleBorderValues = ["dotted", "dashed", "solid", "double", "groove", "ridge", "inset", "outset", "none", "hidden"]; + /// List might include other values than the ones we want for [BorderSide.style], so make sure to remove those before passing it to [ExpressionMapping] + potentialStyles.removeWhere((element) => element == null || !possibleBorderValues.contains(element.text)); + List? borderStyles = potentialStyles; + style.border = ExpressionMapping.expressionToBorder(borderWidths, borderStyles, borderColors); + break; + case 'border-left': + List? borderWidths = value.whereType().toList(); + /// List might include other values than the ones we want for [BorderSide.width], so make sure to remove those before passing it to [ExpressionMapping] + borderWidths.removeWhere((element) => element == null || (element.text != "thin" + && element.text != "medium" && element.text != "thick" + && !(element is css.LengthTerm) && !(element is css.PercentageTerm) + && !(element is css.EmTerm) && !(element is css.RemTerm) + && !(element is css.NumberTerm)) + ); + css.LiteralTerm? borderWidth = borderWidths.firstWhereOrNull((element) => element != null); + css.Expression? borderColor = value.firstWhereOrNull((element) => ExpressionMapping.expressionToColor(element) != null); + List? potentialStyles = value.whereType().toList(); + /// Currently doesn't matter, as Flutter only supports "solid" or "none", but may support more in the future. + List possibleBorderValues = ["dotted", "dashed", "solid", "double", "groove", "ridge", "inset", "outset", "none", "hidden"]; + /// List might include other values than the ones we want for [BorderSide.style], so make sure to remove those before passing it to [ExpressionMapping] + potentialStyles.removeWhere((element) => element == null || !possibleBorderValues.contains(element.text)); + css.LiteralTerm? borderStyle = potentialStyles.firstOrNull; + Border newBorder = Border( + left: style.border?.left.copyWith( + width: ExpressionMapping.expressionToBorderWidth(borderWidth), + style: ExpressionMapping.expressionToBorderStyle(borderStyle), + color: ExpressionMapping.expressionToColor(borderColor), + ) ?? BorderSide( + width: ExpressionMapping.expressionToBorderWidth(borderWidth), + style: ExpressionMapping.expressionToBorderStyle(borderStyle), + color: ExpressionMapping.expressionToColor(borderColor) ?? Colors.black, + ), + right: style.border?.right ?? BorderSide.none, + top: style.border?.top ?? BorderSide.none, + bottom: style.border?.bottom ?? BorderSide.none, + ); + style.border = newBorder; + break; + case 'border-right': + List? borderWidths = value.whereType().toList(); + /// List might include other values than the ones we want for [BorderSide.width], so make sure to remove those before passing it to [ExpressionMapping] + borderWidths.removeWhere((element) => element == null || (element.text != "thin" + && element.text != "medium" && element.text != "thick" + && !(element is css.LengthTerm) && !(element is css.PercentageTerm) + && !(element is css.EmTerm) && !(element is css.RemTerm) + && !(element is css.NumberTerm)) + ); + css.LiteralTerm? borderWidth = borderWidths.firstWhereOrNull((element) => element != null); + css.Expression? borderColor = value.firstWhereOrNull((element) => ExpressionMapping.expressionToColor(element) != null); + List? potentialStyles = value.whereType().toList(); + /// Currently doesn't matter, as Flutter only supports "solid" or "none", but may support more in the future. + List possibleBorderValues = ["dotted", "dashed", "solid", "double", "groove", "ridge", "inset", "outset", "none", "hidden"]; + /// List might include other values than the ones we want for [BorderSide.style], so make sure to remove those before passing it to [ExpressionMapping] + potentialStyles.removeWhere((element) => element == null || !possibleBorderValues.contains(element.text)); + css.LiteralTerm? borderStyle = potentialStyles.firstOrNull; + Border newBorder = Border( + left: style.border?.left ?? BorderSide.none, + right: style.border?.right.copyWith( + width: ExpressionMapping.expressionToBorderWidth(borderWidth), + style: ExpressionMapping.expressionToBorderStyle(borderStyle), + color: ExpressionMapping.expressionToColor(borderColor), + ) ?? BorderSide( + width: ExpressionMapping.expressionToBorderWidth(borderWidth), + style: ExpressionMapping.expressionToBorderStyle(borderStyle), + color: ExpressionMapping.expressionToColor(borderColor) ?? Colors.black, + ), + top: style.border?.top ?? BorderSide.none, + bottom: style.border?.bottom ?? BorderSide.none, + ); + style.border = newBorder; + break; + case 'border-top': + List? borderWidths = value.whereType().toList(); + /// List might include other values than the ones we want for [BorderSide.width], so make sure to remove those before passing it to [ExpressionMapping] + borderWidths.removeWhere((element) => element == null || (element.text != "thin" + && element.text != "medium" && element.text != "thick" + && !(element is css.LengthTerm) && !(element is css.PercentageTerm) + && !(element is css.EmTerm) && !(element is css.RemTerm) + && !(element is css.NumberTerm)) + ); + css.LiteralTerm? borderWidth = borderWidths.firstWhereOrNull((element) => element != null); + css.Expression? borderColor = value.firstWhereOrNull((element) => ExpressionMapping.expressionToColor(element) != null); + List? potentialStyles = value.whereType().toList(); + /// Currently doesn't matter, as Flutter only supports "solid" or "none", but may support more in the future. + List possibleBorderValues = ["dotted", "dashed", "solid", "double", "groove", "ridge", "inset", "outset", "none", "hidden"]; + /// List might include other values than the ones we want for [BorderSide.style], so make sure to remove those before passing it to [ExpressionMapping] + potentialStyles.removeWhere((element) => element == null || !possibleBorderValues.contains(element.text)); + css.LiteralTerm? borderStyle = potentialStyles.firstOrNull; + Border newBorder = Border( + left: style.border?.left ?? BorderSide.none, + right: style.border?.right ?? BorderSide.none, + top: style.border?.top.copyWith( + width: ExpressionMapping.expressionToBorderWidth(borderWidth), + style: ExpressionMapping.expressionToBorderStyle(borderStyle), + color: ExpressionMapping.expressionToColor(borderColor), + ) ?? BorderSide( + width: ExpressionMapping.expressionToBorderWidth(borderWidth), + style: ExpressionMapping.expressionToBorderStyle(borderStyle), + color: ExpressionMapping.expressionToColor(borderColor) ?? Colors.black, + ), + bottom: style.border?.bottom ?? BorderSide.none, + ); + style.border = newBorder; + break; + case 'border-bottom': + List? borderWidths = value.whereType().toList(); + /// List might include other values than the ones we want for [BorderSide.width], so make sure to remove those before passing it to [ExpressionMapping] + borderWidths.removeWhere((element) => element == null || (element.text != "thin" + && element.text != "medium" && element.text != "thick" + && !(element is css.LengthTerm) && !(element is css.PercentageTerm) + && !(element is css.EmTerm) && !(element is css.RemTerm) + && !(element is css.NumberTerm)) + ); + css.LiteralTerm? borderWidth = borderWidths.firstWhereOrNull((element) => element != null); + css.Expression? borderColor = value.firstWhereOrNull((element) => ExpressionMapping.expressionToColor(element) != null); + List? potentialStyles = value.whereType().toList(); + /// Currently doesn't matter, as Flutter only supports "solid" or "none", but may support more in the future. + List possibleBorderValues = ["dotted", "dashed", "solid", "double", "groove", "ridge", "inset", "outset", "none", "hidden"]; + /// List might include other values than the ones we want for [BorderSide.style], so make sure to remove those before passing it to [ExpressionMapping] + potentialStyles.removeWhere((element) => element == null || !possibleBorderValues.contains(element.text)); + css.LiteralTerm? borderStyle = potentialStyles.firstOrNull; + Border newBorder = Border( + left: style.border?.left ?? BorderSide.none, + right: style.border?.right ?? BorderSide.none, + top: style.border?.top ?? BorderSide.none, + bottom: style.border?.bottom.copyWith( + width: ExpressionMapping.expressionToBorderWidth(borderWidth), + style: ExpressionMapping.expressionToBorderStyle(borderStyle), + color: ExpressionMapping.expressionToColor(borderColor), + ) ?? BorderSide( + width: ExpressionMapping.expressionToBorderWidth(borderWidth), + style: ExpressionMapping.expressionToBorderStyle(borderStyle), + color: ExpressionMapping.expressionToColor(borderColor) ?? Colors.black, + ), + ); + style.border = newBorder; + break; + case 'color': + style.color = ExpressionMapping.expressionToColor(value.first) ?? style.color; + break; + case 'direction': + style.direction = ExpressionMapping.expressionToDirection(value.first); + break; + case 'display': + style.display = ExpressionMapping.expressionToDisplay(value.first); + break; + case 'line-height': + style.lineHeight = ExpressionMapping.expressionToLineHeight(value.first); + break; + case 'font-family': + style.fontFamily = ExpressionMapping.expressionToFontFamily(value.first) ?? style.fontFamily; + break; + case 'font-feature-settings': + style.fontFeatureSettings = ExpressionMapping.expressionToFontFeatureSettings(value); + break; + case 'font-size': + style.fontSize = ExpressionMapping.expressionToFontSize(value.first) ?? style.fontSize; + break; + case 'font-style': + style.fontStyle = ExpressionMapping.expressionToFontStyle(value.first); + break; + case 'font-weight': + style.fontWeight = ExpressionMapping.expressionToFontWeight(value.first); + break; + case 'list-style': + css.LiteralTerm? position = value.firstWhereOrNull((e) => e is css.LiteralTerm && (e.text == "outside" || e.text == "inside")) as css.LiteralTerm?; + css.UriTerm? image = value.firstWhereOrNull((e) => e is css.UriTerm) as css.UriTerm?; + css.LiteralTerm? type = value.firstWhereOrNull((e) => e is css.LiteralTerm && e.text != "outside" && e.text != "inside") as css.LiteralTerm?; + if (position != null) { + switch (position.text) { + case 'outside': + style.listStylePosition = ListStylePosition.OUTSIDE; + break; + case 'inside': + style.listStylePosition = ListStylePosition.INSIDE; + break; + } + } + if (image != null) { + style.listStyleType = ExpressionMapping.expressionToListStyleType(image) ?? style.listStyleType; + } else if (type != null) { + style.listStyleType = ExpressionMapping.expressionToListStyleType(type) ?? style.listStyleType; + } + break; + case 'list-style-image': + if (value.first is css.UriTerm) { + style.listStyleType = ExpressionMapping.expressionToListStyleType(value.first as css.UriTerm) ?? style.listStyleType; + } + break; + case 'list-style-position': + if (value.first is css.LiteralTerm) { + switch ((value.first as css.LiteralTerm).text) { + case 'outside': + style.listStylePosition = ListStylePosition.OUTSIDE; + break; + case 'inside': + style.listStylePosition = ListStylePosition.INSIDE; + break; + } + } + break; + case 'height': + style.height = ExpressionMapping.expressionToPaddingLength(value.first) ?? style.height; + break; + case 'list-style-type': + if (value.first is css.LiteralTerm) { + style.listStyleType = ExpressionMapping.expressionToListStyleType(value.first as css.LiteralTerm) ?? style.listStyleType; + } + break; + case 'margin': + List? marginLengths = value.whereType().toList(); + /// List might include other values than the ones we want for margin length, so make sure to remove those before passing it to [ExpressionMapping] + marginLengths.removeWhere((element) => !(element is css.LengthTerm) + && !(element is css.EmTerm) + && !(element is css.RemTerm) + && !(element is css.NumberTerm) + ); + List margin = ExpressionMapping.expressionToPadding(marginLengths); + style.margin = (style.margin ?? EdgeInsets.zero).copyWith( + left: margin[0], + right: margin[1], + top: margin[2], + bottom: margin[3], + ); + break; + case 'margin-left': + style.margin = (style.margin ?? EdgeInsets.zero).copyWith( + left: ExpressionMapping.expressionToPaddingLength(value.first)); + break; + case 'margin-right': + style.margin = (style.margin ?? EdgeInsets.zero).copyWith( + right: ExpressionMapping.expressionToPaddingLength(value.first)); + break; + case 'margin-top': + style.margin = (style.margin ?? EdgeInsets.zero).copyWith( + top: ExpressionMapping.expressionToPaddingLength(value.first)); + break; + case 'margin-bottom': + style.margin = (style.margin ?? EdgeInsets.zero).copyWith( + bottom: ExpressionMapping.expressionToPaddingLength(value.first)); + break; + case 'padding': + List? paddingLengths = value.whereType().toList(); + /// List might include other values than the ones we want for padding length, so make sure to remove those before passing it to [ExpressionMapping] + paddingLengths.removeWhere((element) => !(element is css.LengthTerm) + && !(element is css.EmTerm) + && !(element is css.RemTerm) + && !(element is css.NumberTerm) + ); + List padding = ExpressionMapping.expressionToPadding(paddingLengths); + style.padding = (style.padding ?? EdgeInsets.zero).copyWith( + left: padding[0], + right: padding[1], + top: padding[2], + bottom: padding[3], + ); + break; + case 'padding-left': + style.padding = (style.padding ?? EdgeInsets.zero).copyWith( + left: ExpressionMapping.expressionToPaddingLength(value.first)); + break; + case 'padding-right': + style.padding = (style.padding ?? EdgeInsets.zero).copyWith( + right: ExpressionMapping.expressionToPaddingLength(value.first)); + break; + case 'padding-top': + style.padding = (style.padding ?? EdgeInsets.zero).copyWith( + top: ExpressionMapping.expressionToPaddingLength(value.first)); + break; + case 'padding-bottom': + style.padding = (style.padding ?? EdgeInsets.zero).copyWith( + bottom: ExpressionMapping.expressionToPaddingLength(value.first)); + break; + case 'text-align': + style.textAlign = ExpressionMapping.expressionToTextAlign(value.first); + break; + case 'text-decoration': + List? textDecorationList = value.whereType().toList(); + /// List might include other values than the ones we want for [textDecorationList], so make sure to remove those before passing it to [ExpressionMapping] + textDecorationList.removeWhere((element) => element == null || (element.text != "none" + && element.text != "overline" && element.text != "underline" && element.text != "line-through")); + List? nullableList = value; + css.Expression? textDecorationColor; + textDecorationColor = nullableList.firstWhereOrNull( + (element) => element is css.HexColorTerm || element is css.FunctionTerm); + List? potentialStyles = value.whereType().toList(); + /// List might include other values than the ones we want for [textDecorationStyle], so make sure to remove those before passing it to [ExpressionMapping] + potentialStyles.removeWhere((element) => element == null || (element.text != "solid" + && element.text != "double" && element.text != "dashed" && element.text != "dotted" && element.text != "wavy")); + css.LiteralTerm? textDecorationStyle = potentialStyles.isNotEmpty ? potentialStyles.last : null; + style.textDecoration = ExpressionMapping.expressionToTextDecorationLine(textDecorationList); + if (textDecorationColor != null) style.textDecorationColor = ExpressionMapping.expressionToColor(textDecorationColor) + ?? style.textDecorationColor; + if (textDecorationStyle != null) style.textDecorationStyle = ExpressionMapping.expressionToTextDecorationStyle(textDecorationStyle); + break; + case 'text-decoration-color': + style.textDecorationColor = ExpressionMapping.expressionToColor(value.first) ?? style.textDecorationColor; + break; + case 'text-decoration-line': + List? textDecorationList = value.whereType().toList(); + style.textDecoration = ExpressionMapping.expressionToTextDecorationLine(textDecorationList); + break; + case 'text-decoration-style': + style.textDecorationStyle = ExpressionMapping.expressionToTextDecorationStyle(value.first as css.LiteralTerm); + break; + case 'text-shadow': + style.textShadow = ExpressionMapping.expressionToTextShadow(value); + break; + case 'text-transform': + final val = (value.first as css.LiteralTerm).text; + if (val == 'uppercase') { + style.textTransform = TextTransform.uppercase; + } else if (val == 'lowercase') { + style.textTransform = TextTransform.lowercase; + } else if (val == 'capitalize') { + style.textTransform = TextTransform.capitalize; + } else { + style.textTransform = TextTransform.none; + } + break; + case 'width': + style.width = ExpressionMapping.expressionToPaddingLength(value.first) ?? style.width; + break; + } + } + }); + return style; +} + +Style? inlineCssToStyle(String? inlineStyle, OnCssParseError? errorHandler) { + var errors = []; + final sheet = cssparser.parse("*{$inlineStyle}", errors: errors); + if (errors.isEmpty) { + final declarations = DeclarationVisitor().getDeclarations(sheet); + return declarationsToStyle(declarations["*"]!); + } else if (errorHandler != null) { + String? newCss = errorHandler.call(inlineStyle ?? "", errors); + if (newCss != null) { + return inlineCssToStyle(newCss, errorHandler); + } + } + return null; +} + +Map>> parseExternalCss(String css, OnCssParseError? errorHandler) { + var errors = []; + final sheet = cssparser.parse(css, errors: errors); + if (errors.isEmpty) { + return DeclarationVisitor().getDeclarations(sheet); + } else if (errorHandler != null) { + String? newCss = errorHandler.call(css, errors); + if (newCss != null) { + return parseExternalCss(newCss, errorHandler); + } + } + return {}; +} + +class DeclarationVisitor extends css.Visitor { + Map>> _result = {}; + Map> _properties = {}; + late String _selector; + late String _currentProperty; + + Map>> getDeclarations(css.StyleSheet sheet) { + sheet.topLevels.forEach((element) { + if (element.span != null) { + _selector = element.span!.text; + element.visit(this); + if (_result[_selector] != null) { + _properties.forEach((key, value) { + if (_result[_selector]![key] != null) { + _result[_selector]![key]!.addAll(new List.from(value)); + } else { + _result[_selector]![key] = new List.from(value); + } + }); + } else { + _result[_selector] = new Map>.from(_properties); + } + _properties.clear(); + } + }); + return _result; + } + + @override + void visitDeclaration(css.Declaration node) { + _currentProperty = node.property; + _properties[_currentProperty] = []; + node.expression!.visit(this); + } + + @override + void visitExpressions(css.Expressions node) { + if (_properties[_currentProperty] != null) { + _properties[_currentProperty]!.addAll(node.expressions); + } else { + _properties[_currentProperty] = node.expressions; + } + } +} + +//Mapping functions +class ExpressionMapping { + + static Border expressionToBorder(List? borderWidths, List? borderStyles, List? borderColors) { + CustomBorderSide left = CustomBorderSide(); + CustomBorderSide top = CustomBorderSide(); + CustomBorderSide right = CustomBorderSide(); + CustomBorderSide bottom = CustomBorderSide(); + if (borderWidths != null && borderWidths.isNotEmpty) { + top.width = expressionToBorderWidth(borderWidths.first); + if (borderWidths.length == 4) { + right.width = expressionToBorderWidth(borderWidths[1]); + bottom.width = expressionToBorderWidth(borderWidths[2]); + left.width = expressionToBorderWidth(borderWidths.last); + } + if (borderWidths.length == 3) { + left.width = expressionToBorderWidth(borderWidths[1]); + right.width = expressionToBorderWidth(borderWidths[1]); + bottom.width = expressionToBorderWidth(borderWidths.last); + } + if (borderWidths.length == 2) { + bottom.width = expressionToBorderWidth(borderWidths.first); + left.width = expressionToBorderWidth(borderWidths.last); + right.width = expressionToBorderWidth(borderWidths.last); + } + if (borderWidths.length == 1) { + bottom.width = expressionToBorderWidth(borderWidths.first); + left.width = expressionToBorderWidth(borderWidths.first); + right.width = expressionToBorderWidth(borderWidths.first); + } + } + if (borderStyles != null && borderStyles.isNotEmpty) { + top.style = expressionToBorderStyle(borderStyles.first); + if (borderStyles.length == 4) { + right.style = expressionToBorderStyle(borderStyles[1]); + bottom.style = expressionToBorderStyle(borderStyles[2]); + left.style = expressionToBorderStyle(borderStyles.last); + } + if (borderStyles.length == 3) { + left.style = expressionToBorderStyle(borderStyles[1]); + right.style = expressionToBorderStyle(borderStyles[1]); + bottom.style = expressionToBorderStyle(borderStyles.last); + } + if (borderStyles.length == 2) { + bottom.style = expressionToBorderStyle(borderStyles.first); + left.style = expressionToBorderStyle(borderStyles.last); + right.style = expressionToBorderStyle(borderStyles.last); + } + if (borderStyles.length == 1) { + bottom.style = expressionToBorderStyle(borderStyles.first); + left.style = expressionToBorderStyle(borderStyles.first); + right.style = expressionToBorderStyle(borderStyles.first); + } + } + if (borderColors != null && borderColors.isNotEmpty) { + top.color = expressionToColor(borderColors.first); + if (borderColors.length == 4) { + right.color = expressionToColor(borderColors[1]); + bottom.color = expressionToColor(borderColors[2]); + left.color = expressionToColor(borderColors.last); + } + if (borderColors.length == 3) { + left.color = expressionToColor(borderColors[1]); + right.color = expressionToColor(borderColors[1]); + bottom.color = expressionToColor(borderColors.last); + } + if (borderColors.length == 2) { + bottom.color = expressionToColor(borderColors.first); + left.color = expressionToColor(borderColors.last); + right.color = expressionToColor(borderColors.last); + } + if (borderColors.length == 1) { + bottom.color = expressionToColor(borderColors.first); + left.color = expressionToColor(borderColors.first); + right.color = expressionToColor(borderColors.first); + } + } + return Border( + top: BorderSide(width: top.width, color: top.color ?? Colors.black, style: top.style), + right: BorderSide(width: right.width, color: right.color ?? Colors.black, style: right.style), + bottom: BorderSide(width: bottom.width, color: bottom.color ?? Colors.black, style: bottom.style), + left: BorderSide(width: left.width, color: left.color ?? Colors.black, style: left.style) + ); + } + + static double expressionToBorderWidth(css.Expression? value) { + if (value is css.NumberTerm) { + return double.tryParse(value.text) ?? 1.0; + } else if (value is css.PercentageTerm) { + return (double.tryParse(value.text) ?? 400) / 100; + } else if (value is css.EmTerm) { + return double.tryParse(value.text) ?? 1.0; + } else if (value is css.RemTerm) { + return double.tryParse(value.text) ?? 1.0; + } else if (value is css.LengthTerm) { + return double.tryParse(value.text.replaceAll(new RegExp(r'\s+(\d+\.\d+)\s+'), '')) ?? 1.0; + } else if (value is css.LiteralTerm) { + switch (value.text) { + case "thin": + return 2.0; + case "medium": + return 4.0; + case "thick": + return 6.0; + } + } + return 4.0; + } + + static BorderStyle expressionToBorderStyle(css.LiteralTerm? value) { + if (value != null && value.text != "none" && value.text != "hidden") { + return BorderStyle.solid; + } + return BorderStyle.none; + } + + static Color? expressionToColor(css.Expression? value) { + if (value != null) { + if (value is css.HexColorTerm) { + return stringToColor(value.text); + } else if (value is css.FunctionTerm) { + if (value.text == 'rgba' || value.text == 'rgb') { + return rgbOrRgbaToColor(value.span!.text); + } else if (value.text == 'hsla' || value.text == 'hsl') { + return hslToRgbToColor(value.span!.text); + } + } else if (value is css.LiteralTerm) { + return namedColorToColor(value.text); + } + } + return null; + } + + static TextDirection expressionToDirection(css.Expression value) { + if (value is css.LiteralTerm) { + switch(value.text) { + case "ltr": + return TextDirection.ltr; + case "rtl": + return TextDirection.rtl; + } + } + return TextDirection.ltr; + } + + static Display expressionToDisplay(css.Expression value) { + if (value is css.LiteralTerm) { + switch(value.text) { + case 'block': + return Display.BLOCK; + case 'inline-block': + return Display.INLINE_BLOCK; + case 'inline': + return Display.INLINE; + case 'list-item': + return Display.LIST_ITEM; + case 'none': + return Display.NONE; + } + } + return Display.INLINE; + } + + static List expressionToFontFeatureSettings(List value) { + List fontFeatures = []; + for (int i = 0; i < value.length; i++) { + css.Expression exp = value[i]; + if (exp is css.LiteralTerm) { + if (exp.text != "on" && exp.text != "off" && exp.text != "1" && exp.text != "0") { + if (i < value.length - 1) { + css.Expression nextExp = value[i+1]; + if (nextExp is css.LiteralTerm && (nextExp.text == "on" || nextExp.text == "off" || nextExp.text == "1" || nextExp.text == "0")) { + fontFeatures.add(FontFeature(exp.text, nextExp.text == "on" || nextExp.text == "1" ? 1 : 0)); + } else { + fontFeatures.add(FontFeature.enable(exp.text)); + } + } else { + fontFeatures.add(FontFeature.enable(exp.text)); + } + } + } + } + List finalFontFeatures = fontFeatures.toSet().toList(); + return finalFontFeatures; + } + + static FontSize? expressionToFontSize(css.Expression value) { + if (value is css.NumberTerm) { + return FontSize(double.tryParse(value.text)); + } else if (value is css.PercentageTerm) { + return FontSize.percent(double.tryParse(value.text)!); + } else if (value is css.EmTerm) { + return FontSize.em(double.tryParse(value.text)); + } else if (value is css.RemTerm) { + return FontSize.rem(double.tryParse(value.text)!); + } else if (value is css.LengthTerm) { + return FontSize(double.tryParse(value.text.replaceAll(new RegExp(r'\s+(\d+\.\d+)\s+'), ''))); + } else if (value is css.LiteralTerm) { + switch (value.text) { + case "xx-small": + return FontSize.xxSmall; + case "x-small": + return FontSize.xSmall; + case "small": + return FontSize.small; + case "medium": + return FontSize.medium; + case "large": + return FontSize.large; + case "x-large": + return FontSize.xLarge; + case "xx-large": + return FontSize.xxLarge; + } + } + return null; + } + + static FontStyle expressionToFontStyle(css.Expression value) { + if (value is css.LiteralTerm) { + switch(value.text) { + case "italic": + case "oblique": + return FontStyle.italic; + } + return FontStyle.normal; + } + return FontStyle.normal; + } + + static FontWeight expressionToFontWeight(css.Expression value) { + if (value is css.NumberTerm) { + switch (value.text) { + case "100": + return FontWeight.w100; + case "200": + return FontWeight.w200; + case "300": + return FontWeight.w300; + case "400": + return FontWeight.w400; + case "500": + return FontWeight.w500; + case "600": + return FontWeight.w600; + case "700": + return FontWeight.w700; + case "800": + return FontWeight.w800; + case "900": + return FontWeight.w900; + } + } else if (value is css.LiteralTerm) { + switch(value.text) { + case "bold": + return FontWeight.bold; + case "bolder": + return FontWeight.w900; + case "lighter": + return FontWeight.w200; + } + return FontWeight.normal; + } + return FontWeight.normal; + } + + static String? expressionToFontFamily(css.Expression value) { + if (value is css.LiteralTerm) return value.text; + return null; + } + + static LineHeight expressionToLineHeight(css.Expression value) { + if (value is css.NumberTerm) { + return LineHeight.number(double.tryParse(value.text)!); + } else if (value is css.PercentageTerm) { + return LineHeight.percent(double.tryParse(value.text)!); + } else if (value is css.EmTerm) { + return LineHeight.em(double.tryParse(value.text)!); + } else if (value is css.RemTerm) { + return LineHeight.rem(double.tryParse(value.text)!); + } else if (value is css.LengthTerm) { + return LineHeight(double.tryParse(value.text.replaceAll(new RegExp(r'\s+(\d+\.\d+)\s+'), '')), units: "length"); + } + return LineHeight.normal; + } + + static ListStyleType? expressionToListStyleType(css.LiteralTerm value) { + if (value is css.UriTerm) { + return ListStyleType.fromImage(value.text); + } + switch (value.text) { + case 'disc': + return ListStyleType.DISC; + case 'circle': + return ListStyleType.CIRCLE; + case 'decimal': + return ListStyleType.DECIMAL; + case 'lower-alpha': + return ListStyleType.LOWER_ALPHA; + case 'lower-latin': + return ListStyleType.LOWER_LATIN; + case 'lower-roman': + return ListStyleType.LOWER_ROMAN; + case 'square': + return ListStyleType.SQUARE; + case 'upper-alpha': + return ListStyleType.UPPER_ALPHA; + case 'upper-latin': + return ListStyleType.UPPER_LATIN; + case 'upper-roman': + return ListStyleType.UPPER_ROMAN; + case 'none': + return ListStyleType.NONE; + } + return null; + } + + static List expressionToPadding(List? lengths) { + double? left; + double? right; + double? top; + double? bottom; + if (lengths != null && lengths.isNotEmpty) { + top = expressionToPaddingLength(lengths.first); + if (lengths.length == 4) { + right = expressionToPaddingLength(lengths[1]); + bottom = expressionToPaddingLength(lengths[2]); + left = expressionToPaddingLength(lengths.last); + } + if (lengths.length == 3) { + left = expressionToPaddingLength(lengths[1]); + right = expressionToPaddingLength(lengths[1]); + bottom = expressionToPaddingLength(lengths.last); + } + if (lengths.length == 2) { + bottom = expressionToPaddingLength(lengths.first); + left = expressionToPaddingLength(lengths.last); + right = expressionToPaddingLength(lengths.last); + } + if (lengths.length == 1) { + bottom = expressionToPaddingLength(lengths.first); + left = expressionToPaddingLength(lengths.first); + right = expressionToPaddingLength(lengths.first); + } + } + return [left, right, top, bottom]; + } + + static double? expressionToPaddingLength(css.Expression value) { + if (value is css.NumberTerm) { + return double.tryParse(value.text); + } else if (value is css.EmTerm) { + return double.tryParse(value.text); + } else if (value is css.RemTerm) { + return double.tryParse(value.text); + } else if (value is css.LengthTerm) { + return double.tryParse(value.text.replaceAll(new RegExp(r'\s+(\d+\.\d+)\s+'), '')); + } + return null; + } + + static TextAlign expressionToTextAlign(css.Expression value) { + if (value is css.LiteralTerm) { + switch(value.text) { + case "center": + return TextAlign.center; + case "left": + return TextAlign.left; + case "right": + return TextAlign.right; + case "justify": + return TextAlign.justify; + case "end": + return TextAlign.end; + case "start": + return TextAlign.start; + } + } + return TextAlign.start; + } + + static TextDecoration expressionToTextDecorationLine(List value) { + List decorationList = []; + for (css.LiteralTerm? term in value) { + if (term != null) { + switch(term.text) { + case "overline": + decorationList.add(TextDecoration.overline); + break; + case "underline": + decorationList.add(TextDecoration.underline); + break; + case "line-through": + decorationList.add(TextDecoration.lineThrough); + break; + default: + decorationList.add(TextDecoration.none); + break; + } + } + } + if (decorationList.contains(TextDecoration.none)) decorationList = [TextDecoration.none]; + return TextDecoration.combine(decorationList); + } + + static TextDecorationStyle expressionToTextDecorationStyle(css.LiteralTerm value) { + switch(value.text) { + case "wavy": + return TextDecorationStyle.wavy; + case "dotted": + return TextDecorationStyle.dotted; + case "dashed": + return TextDecorationStyle.dashed; + case "double": + return TextDecorationStyle.double; + default: + return TextDecorationStyle.solid; + } + } + + static List expressionToTextShadow(List value) { + List shadow = []; + List indices = []; + List> valueList = []; + for (css.Expression e in value) { + if (e is css.OperatorComma) { + indices.add(value.indexOf(e)); + } + } + indices.add(value.length); + int previousIndex = 0; + for (int i in indices) { + valueList.add(value.sublist(previousIndex, i)); + previousIndex = i + 1; + } + for (List list in valueList) { + css.Expression? offsetX; + css.Expression? offsetY; + css.Expression? blurRadius; + css.Expression? color; + int expressionIndex = 0; + list.forEach((element) { + if (element is css.HexColorTerm || element is css.FunctionTerm) { + color = element; + } else if (expressionIndex == 0) { + offsetX = element; + expressionIndex++; + } else if (expressionIndex++ == 1) { + offsetY = element; + expressionIndex++; + } else { + blurRadius = element; + } + }); + RegExp nonNumberRegex = RegExp(r'\s+(\d+\.\d+)\s+'); + if (offsetX is css.LiteralTerm && offsetY is css.LiteralTerm) { + if (color != null && ExpressionMapping.expressionToColor(color) != null) { + shadow.add(Shadow( + color: expressionToColor(color)!, + offset: Offset( + double.tryParse((offsetX as css.LiteralTerm).text.replaceAll(nonNumberRegex, ''))!, + double.tryParse((offsetY as css.LiteralTerm).text.replaceAll(nonNumberRegex, ''))!), + blurRadius: (blurRadius is css.LiteralTerm) ? double.tryParse((blurRadius as css.LiteralTerm).text.replaceAll(nonNumberRegex, ''))! : 0.0, + )); + } else { + shadow.add(Shadow( + offset: Offset( + double.tryParse((offsetX as css.LiteralTerm).text.replaceAll(nonNumberRegex, ''))!, + double.tryParse((offsetY as css.LiteralTerm).text.replaceAll(nonNumberRegex, ''))!), + blurRadius: (blurRadius is css.LiteralTerm) ? double.tryParse((blurRadius as css.LiteralTerm).text.replaceAll(nonNumberRegex, ''))! : 0.0, + )); + } + } + } + List finalShadows = shadow.toSet().toList(); + return finalShadows; + } + + static Color stringToColor(String _text) { + var text = _text.replaceFirst('#', ''); + if (text.length == 3) + text = text.replaceAllMapped( + RegExp(r"[a-f]|\d", caseSensitive: false), + (match) => '${match.group(0)}${match.group(0)}' + ); + if (text.length > 6) { + text = "0x" + text; + } else { + text = "0xFF" + text; + } + return new Color(int.parse(text)); + } + + static Color? rgbOrRgbaToColor(String text) { + final rgbaText = text.replaceAll(')', '').replaceAll(' ', ''); + try { + final rgbaValues = + rgbaText.split(',').map((value) => double.parse(value)).toList(); + if (rgbaValues.length == 4) { + return Color.fromRGBO( + rgbaValues[0].toInt(), + rgbaValues[1].toInt(), + rgbaValues[2].toInt(), + rgbaValues[3], + ); + } else if (rgbaValues.length == 3) { + return Color.fromRGBO( + rgbaValues[0].toInt(), + rgbaValues[1].toInt(), + rgbaValues[2].toInt(), + 1.0, + ); + } + return null; + } catch (e) { + return null; + } + } + + static Color hslToRgbToColor(String text) { + final hslText = text.replaceAll(')', '').replaceAll(' ', ''); + final hslValues = hslText.split(',').toList(); + List parsedHsl = []; + hslValues.forEach((element) { + if (element.contains("%") && double.tryParse(element.replaceAll("%", "")) != null) { + parsedHsl.add(double.tryParse(element.replaceAll("%", ""))! * 0.01); + } else { + if (element != hslValues.first && (double.tryParse(element) == null || double.tryParse(element)! > 1)) { + parsedHsl.add(null); + } else { + parsedHsl.add(double.tryParse(element)); + } + } + }); + if (parsedHsl.length == 4 && !parsedHsl.contains(null)) { + return HSLColor.fromAHSL(parsedHsl.last!, parsedHsl.first!, parsedHsl[1]!, parsedHsl[2]!).toColor(); + } else if (parsedHsl.length == 3 && !parsedHsl.contains(null)) { + return HSLColor.fromAHSL(1.0, parsedHsl.first!, parsedHsl[1]!, parsedHsl.last!).toColor(); + } else return Colors.black; + } + + static Color? namedColorToColor(String text) { + String namedColor = namedColors.keys.firstWhere((element) => element.toLowerCase() == text.toLowerCase(), orElse: () => ""); + if (namedColor != "") { + return stringToColor(namedColors[namedColor]!); + } else return null; + } +} diff --git a/bytedesk_kefu/lib/vendors/flutter_html/src/html_elements.dart b/bytedesk_kefu/lib/vendors/flutter_html/src/html_elements.dart new file mode 100644 index 0000000..4b096d8 --- /dev/null +++ b/bytedesk_kefu/lib/vendors/flutter_html/src/html_elements.dart @@ -0,0 +1,283 @@ +export 'styled_element.dart'; +export 'interactable_element.dart'; +export 'replaced_element.dart'; + +const STYLED_ELEMENTS = [ + "abbr", + "acronym", + "address", + "b", + "bdi", + "bdo", + "big", + "cite", + "code", + "data", + "del", + "dfn", + "em", + "font", + "i", + "ins", + "kbd", + "mark", + "q", + "rt", + "s", + "samp", + "small", + "span", + "strike", + "strong", + "sub", + "sup", + "time", + "tt", + "u", + "var", + "wbr", + + //BLOCK ELEMENTS + "article", + "aside", + "blockquote", + "body", + "center", + "dd", + "div", + "dl", + "dt", + "figcaption", + "figure", + "footer", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "header", + "hr", + "html", + "li", + "main", + "nav", + "noscript", + "ol", + "p", + "pre", + "section", + "summary", + "ul", +]; + +const BLOCK_ELEMENTS = [ + "article", + "aside", + "blockquote", + "body", + "center", + "dd", + "div", + "dl", + "dt", + "figcaption", + "figure", + "footer", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "header", + "hr", + "html", + "li", + "main", + "nav", + "noscript", + "ol", + "p", + "pre", + "section", + "summary", + "ul", +]; + +const INTERACTABLE_ELEMENTS = [ + "a", +]; + +const REPLACED_ELEMENTS = [ + "br", + "template", + "rp", + "rt", + "ruby", +]; + +const LAYOUT_ELEMENTS = [ + "details", + "tr", + "tbody", + "tfoot", + "thead", +]; + +const TABLE_CELL_ELEMENTS = ["th", "td"]; + +const TABLE_DEFINITION_ELEMENTS = ["col", "colgroup"]; + +const EXTERNAL_ELEMENTS = ["audio", "iframe", "img", "math", "svg", "table", "video"]; + +const SELECTABLE_ELEMENTS = [ + "br", + "a", + "article", + "aside", + "blockquote", + "body", + "center", + "dd", + "div", + "dl", + "dt", + "figcaption", + "figure", + "footer", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "header", + "hr", + "html", + "main", + "nav", + "noscript", + "p", + "pre", + "section", + "summary", + "abbr", + "acronym", + "address", + "b", + "bdi", + "bdo", + "big", + "cite", + "code", + "data", + "del", + "dfn", + "em", + "font", + "i", + "ins", + "kbd", + "mark", + "q", + "s", + "samp", + "small", + "span", + "strike", + "strong", + "time", + "tt", + "u", + "var", + "wbr", +]; + +/** + Here is a list of elements with planned support: + a - i [x] + abbr - s [x] + acronym - s [x] + address - s [x] + audio - c [x] + article - b [x] + aside - b [x] + b - s [x] + bdi - s [x] + bdo - s [x] + big - s [x] + blockquote- b [x] + body - b [x] + br - b [x] + button - i [ ] + caption - b [ ] + center - b [x] + cite - s [x] + code - s [x] + data - s [x] + dd - b [x] + del - s [x] + dfn - s [x] + div - b [x] + dl - b [x] + dt - b [x] + em - s [x] + figcaption- b [x] + figure - b [x] + font - s [x] + footer - b [x] + h1 - b [x] + h2 - b [x] + h3 - b [x] + h4 - b [x] + h5 - b [x] + h6 - b [x] + head - e [x] + header - b [x] + hr - b [x] + html - b [x] + i - s [x] + img - c [x] + ins - s [x] + kbd - s [x] + li - b [x] + main - b [x] + mark - s [x] + nav - b [x] + noscript - b [x] + ol - b [x] post + p - b [x] + pre - b [x] + q - s [x] post + rp - s [x] + rt - s [x] + ruby - s [x] + s - s [x] + samp - s [x] + section - b [x] + small - s [x] + source - [-] child of content + span - s [x] + strike - s [x] + strong - s [x] + sub - s [x] + sup - s [x] + svg - c [x] + table - b [x] + tbody - b [x] + td - s [ ] + template - e [x] + tfoot - b [x] + th - s [ ] + thead - b [x] + time - s [x] + tr - ? [ ] + track - [-] child of content + tt - s [x] + u - s [x] + ul - b [x] post + var - s [x] + video - c [x] + wbr - s [x] + */ diff --git a/bytedesk_kefu/lib/vendors/flutter_html/src/interactable_element.dart b/bytedesk_kefu/lib/vendors/flutter_html/src/interactable_element.dart new file mode 100644 index 0000000..03e5e81 --- /dev/null +++ b/bytedesk_kefu/lib/vendors/flutter_html/src/interactable_element.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; +import './html_elements.dart'; +import '../style.dart'; +import 'package:html/dom.dart' as dom; + +/// An [InteractableElement] is a [StyledElement] that takes user gestures (e.g. tap). +class InteractableElement extends StyledElement { + String? href; + + InteractableElement({ + required String name, + required List children, + required Style style, + required this.href, + required dom.Node node, + required String elementId, + }) : super(name: name, children: children, style: style, node: node as dom.Element?, elementId: elementId); +} + +/// A [Gesture] indicates the type of interaction by a user. +enum Gesture { + TAP, +} + +StyledElement parseInteractableElement( + dom.Element element, List children) { + switch (element.localName) { + case "a": + if (element.attributes.containsKey('href')) { + return InteractableElement( + name: element.localName!, + children: children, + href: element.attributes['href'], + style: Style( + color: Colors.blue, + textDecoration: TextDecoration.underline, + ), + node: element, + elementId: element.id + ); + } + // When
tag have no href, it must be non clickable and without decoration. + return StyledElement( + name: element.localName!, + children: children, + style: Style(), + node: element, + elementId: element.id, + ); + /// will never be called, just to suppress missing return warning + default: + return InteractableElement( + name: element.localName!, + children: children, + node: element, + href: '', + style: Style(), + elementId: "[[No ID]]" + ); + } +} diff --git a/bytedesk_kefu/lib/vendors/flutter_html/src/layout_element.dart b/bytedesk_kefu/lib/vendors/flutter_html/src/layout_element.dart new file mode 100644 index 0000000..2b9ae19 --- /dev/null +++ b/bytedesk_kefu/lib/vendors/flutter_html/src/layout_element.dart @@ -0,0 +1,214 @@ +import 'package:flutter/material.dart'; +import '../html_parser.dart'; +import './anchor.dart'; +import './html_elements.dart'; +import './styled_element.dart'; +import '../style.dart'; +import 'package:html/dom.dart' as dom; + +/// A [LayoutElement] is an element that breaks the normal Inline flow of +/// an html document with a more complex layout. LayoutElements handle +abstract class LayoutElement extends StyledElement { + LayoutElement({ + String name = "[[No Name]]", + required List children, + String? elementId, + dom.Element? node, + }) : super(name: name, children: children, style: Style(), node: node, elementId: elementId ?? "[[No ID]]"); + + Widget? toWidget(RenderContext context); +} + +class TableSectionLayoutElement extends LayoutElement { + TableSectionLayoutElement({ + required String name, + required List children, + }) : super(name: name, children: children); + + @override + Widget toWidget(RenderContext context) { + // Not rendered; TableLayoutElement will instead consume its children + return Container(child: Text("TABLE SECTION")); + } +} + +class TableRowLayoutElement extends LayoutElement { + TableRowLayoutElement({ + required String name, + required List children, + required dom.Element node, + }) : super(name: name, children: children, node: node); + + @override + Widget toWidget(RenderContext context) { + // Not rendered; TableLayoutElement will instead consume its children + return Container(child: Text("TABLE ROW")); + } +} + +class TableCellElement extends StyledElement { + int colspan = 1; + int rowspan = 1; + + TableCellElement({ + required String name, + required String elementId, + required List elementClasses, + required List children, + required Style style, + required dom.Element node, + }) : super(name: name, elementId: elementId, elementClasses: elementClasses, children: children, style: style, node: node) { + colspan = _parseSpan(this, "colspan"); + rowspan = _parseSpan(this, "rowspan"); + } + + static int _parseSpan(StyledElement element, String attributeName) { + final spanValue = element.attributes[attributeName]; + return spanValue == null ? 1 : int.tryParse(spanValue) ?? 1; + } +} + +TableCellElement parseTableCellElement( + dom.Element element, + List children, +) { + final cell = TableCellElement( + name: element.localName!, + elementId: element.id, + elementClasses: element.classes.toList(), + children: children, + node: element, + style: Style(), + ); + if (element.localName == "th") { + cell.style = Style( + fontWeight: FontWeight.bold, + ); + } + return cell; +} + +class TableStyleElement extends StyledElement { + TableStyleElement({ + required String name, + required List children, + required Style style, + required dom.Element node, + }) : super(name: name, children: children, style: style, node: node); +} + +TableStyleElement parseTableDefinitionElement( + dom.Element element, + List children, +) { + switch (element.localName) { + case "colgroup": + case "col": + return TableStyleElement( + name: element.localName!, + children: children, + node: element, + style: Style(), + ); + default: + return TableStyleElement( + name: "[[No Name]]", + children: children, + node: element, + style: Style(), + ); + } +} + +class DetailsContentElement extends LayoutElement { + List elementList; + + DetailsContentElement({ + required String name, + required List children, + required dom.Element node, + required this.elementList, + }) : super(name: name, node: node, children: children, elementId: node.id); + + @override + Widget toWidget(RenderContext context) { + List? childrenList = children.map((tree) => context.parser.parseTree(context, tree)).toList(); + List toRemove = []; + for (InlineSpan child in childrenList) { + if (child is TextSpan && child.text != null && child.text!.trim().isEmpty) { + toRemove.add(child); + } + } + for (InlineSpan child in toRemove) { + childrenList.remove(child); + } + InlineSpan? firstChild = childrenList.isNotEmpty == true ? childrenList.first : null; + return ExpansionTile( + key: AnchorKey.of(context.parser.key, this), + expandedAlignment: Alignment.centerLeft, + title: elementList.isNotEmpty == true && elementList.first.localName == "summary" ? StyledText( + textSpan: TextSpan( + style: style.generateTextStyle(), + children: firstChild == null ? [] : [firstChild], + ), + style: style, + renderContext: context, + ) : Text("Details"), + children: [ + StyledText( + textSpan: TextSpan( + style: style.generateTextStyle(), + children: getChildren(childrenList, context, elementList.isNotEmpty == true && elementList.first.localName == "summary" ? firstChild : null) + ), + style: style, + renderContext: context, + ), + ] + ); + } + + List getChildren(List children, RenderContext context, InlineSpan? firstChild) { + if (firstChild != null) children.removeAt(0); + return children; + } +} + +class EmptyLayoutElement extends LayoutElement { + EmptyLayoutElement({required String name}) : super(name: name, children: []); + + @override + Widget? toWidget(_) => null; +} + +LayoutElement parseLayoutElement( + dom.Element element, + List children, +) { + switch (element.localName) { + case "details": + if (children.isEmpty) { + return EmptyLayoutElement(name: "empty"); + } + return DetailsContentElement( + node: element, + name: element.localName!, + children: children, + elementList: element.children + ); + case "thead": + case "tbody": + case "tfoot": + return TableSectionLayoutElement( + name: element.localName!, + children: children, + ); + case "tr": + return TableRowLayoutElement( + name: element.localName!, + children: children, + node: element, + ); + default: + return EmptyLayoutElement(name: "[[No Name]]"); + } +} diff --git a/bytedesk_kefu/lib/vendors/flutter_html/src/replaced_element.dart b/bytedesk_kefu/lib/vendors/flutter_html/src/replaced_element.dart new file mode 100644 index 0000000..b16f80c --- /dev/null +++ b/bytedesk_kefu/lib/vendors/flutter_html/src/replaced_element.dart @@ -0,0 +1,166 @@ +import 'dart:math'; + +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; +import '../html_parser.dart'; +import './anchor.dart'; +import './html_elements.dart'; +import '../style.dart'; +import 'package:html/dom.dart' as dom; + +/// A [ReplacedElement] is a type of [StyledElement] that does not require its [children] to be rendered. +/// +/// A [ReplacedElement] may use its children nodes to determine relevant information +/// (e.g.