From aba3cbbe4cfc644f2dc8ba70c583613052a8c3e4 Mon Sep 17 00:00:00 2001 From: dab246 Date: Tue, 18 Nov 2025 10:15:51 +0700 Subject: [PATCH] TF-4152 Fix tooltip stay out of screen in Email View --- core/lib/core.dart | 1 + .../dom/remove_tooltip_link_transformers.dart | 41 ----- ...e_hyper_link_tag_in_html_transformers.dart | 23 +-- .../transform_configuration.dart | 11 +- .../html_content_viewer_on_web_widget.dart | 63 ++++++- .../views/tooltip/iframe_tooltip_overlay.dart | 161 ++++++++++++++++++ core/lib/utils/html/html_interaction.dart | 35 ++++ core/lib/utils/html/html_template.dart | 21 --- .../email/presentation/email_view.dart | 7 + .../event_body_content_widget.dart | 7 + .../email_previewer/email_previewer_view.dart | 7 + .../identities/widgets/signature_builder.dart | 7 + 12 files changed, 291 insertions(+), 93 deletions(-) delete mode 100644 core/lib/presentation/utils/html_transformer/dom/remove_tooltip_link_transformers.dart create mode 100644 core/lib/presentation/views/tooltip/iframe_tooltip_overlay.dart diff --git a/core/lib/core.dart b/core/lib/core.dart index 8fa332280..f79050fd9 100644 --- a/core/lib/core.dart +++ b/core/lib/core.dart @@ -111,6 +111,7 @@ export 'presentation/views/semantics/checkbox_semantics.dart'; export 'presentation/views/semantics/text_field_semantics.dart'; export 'presentation/views/semantics/icon_semantics.dart'; export 'presentation/views/shortcut/key_shortcut.dart'; +export 'presentation/views/tooltip/iframe_tooltip_overlay.dart'; // Resources export 'presentation/resources/assets_paths.dart'; diff --git a/core/lib/presentation/utils/html_transformer/dom/remove_tooltip_link_transformers.dart b/core/lib/presentation/utils/html_transformer/dom/remove_tooltip_link_transformers.dart deleted file mode 100644 index 68085ca58..000000000 --- a/core/lib/presentation/utils/html_transformer/dom/remove_tooltip_link_transformers.dart +++ /dev/null @@ -1,41 +0,0 @@ - -import 'package:core/data/network/dio_client.dart'; -import 'package:core/presentation/utils/html_transformer/base/dom_transformer.dart'; -import 'package:core/utils/app_logger.dart'; -import 'package:core/utils/html/html_template.dart'; -import 'package:html/dom.dart'; - -class RemoveTooltipLinkTransformer extends DomTransformer { - - const RemoveTooltipLinkTransformer(); - - @override - Future process({ - required Document document, - required DioClient dioClient, - Map? mapUrlDownloadCID, - }) async { - try { - final linkElements = document.querySelectorAll('a.${HtmlTemplate.nameClassToolTip}'); - - if (linkElements.isEmpty) return; - - await Future.wait(linkElements.map((linkElement) async { - final classAttribute = linkElement.attributes['class']; - if (classAttribute != null) { - final newClassAttribute = classAttribute.replaceFirst(HtmlTemplate.nameClassToolTip, ''); - linkElement.attributes['class'] = newClassAttribute; - } - final listSpanTag = linkElement.querySelectorAll('span.tooltiptext'); - if (listSpanTag.isNotEmpty) { - for (var element in listSpanTag) { - element.remove(); - } - } - })); - } catch (e) { - logError('$runtimeType::process:Exception = $e'); - } - } - -} \ No newline at end of file diff --git a/core/lib/presentation/utils/html_transformer/dom/sanitize_hyper_link_tag_in_html_transformers.dart b/core/lib/presentation/utils/html_transformer/dom/sanitize_hyper_link_tag_in_html_transformers.dart index 812660404..3d1de54ff 100644 --- a/core/lib/presentation/utils/html_transformer/dom/sanitize_hyper_link_tag_in_html_transformers.dart +++ b/core/lib/presentation/utils/html_transformer/dom/sanitize_hyper_link_tag_in_html_transformers.dart @@ -2,14 +2,12 @@ import 'package:core/data/network/dio_client.dart'; import 'package:core/presentation/utils/html_transformer/base/dom_transformer.dart'; import 'package:core/presentation/utils/html_transformer/sanitize_url.dart'; import 'package:core/utils/app_logger.dart'; -import 'package:core/utils/html/html_template.dart'; import 'package:html/dom.dart'; class SanitizeHyperLinkTagInHtmlTransformer extends DomTransformer { final _sanitizeUrl = SanitizeUrl(); - final bool useTooltip; - SanitizeHyperLinkTagInHtmlTransformer({this.useTooltip = false}); + SanitizeHyperLinkTagInHtmlTransformer(); @override Future process({ @@ -24,9 +22,6 @@ class SanitizeHyperLinkTagInHtmlTransformer extends DomTransformer { await Future.wait(elements.map((element) async { _sanitizeUrlResource(element); - if (useTooltip) { - _addToolTipWhenHoverLink(element); - } _addBlankForTargetProperty(element); _addNoReferrerForRelProperty(element); })); @@ -46,22 +41,6 @@ class SanitizeHyperLinkTagInHtmlTransformer extends DomTransformer { element.attributes['href'] = urlSanitized; } - void _addToolTipWhenHoverLink(Element element) { - final url = element.attributes['href'] ?? ''; - final text = element.text; - final children = element.children; - if (children.isEmpty && text.isNotEmpty) { - final innerHtml = element.innerHtml; - final tagClass = element.attributes['class']; - if (tagClass != null) { - element.attributes['class'] = '$tagClass ${HtmlTemplate.nameClassToolTip}'; - } else { - element.attributes['class'] = HtmlTemplate.nameClassToolTip; - } - element.innerHtml = '$innerHtml $url'; - } - } - void _addBlankForTargetProperty(Element element) { element.attributes['target'] = '_blank'; } diff --git a/core/lib/presentation/utils/html_transformer/transform_configuration.dart b/core/lib/presentation/utils/html_transformer/transform_configuration.dart index 4cdf9d701..0872184e8 100644 --- a/core/lib/presentation/utils/html_transformer/transform_configuration.dart +++ b/core/lib/presentation/utils/html_transformer/transform_configuration.dart @@ -12,7 +12,6 @@ import 'package:core/presentation/utils/html_transformer/dom/remove_lazy_loading import 'package:core/presentation/utils/html_transformer/dom/remove_lazy_loading_image_transformers.dart'; import 'package:core/presentation/utils/html_transformer/dom/remove_max_width_in_image_style_transformers.dart'; import 'package:core/presentation/utils/html_transformer/dom/remove_style_tag_outside_transformers.dart'; -import 'package:core/presentation/utils/html_transformer/dom/remove_tooltip_link_transformers.dart'; import 'package:core/presentation/utils/html_transformer/dom/sanitize_hyper_link_tag_in_html_transformers.dart'; import 'package:core/presentation/utils/html_transformer/dom/script_transformers.dart'; import 'package:core/presentation/utils/html_transformer/dom/signature_transformers.dart'; @@ -43,8 +42,6 @@ class TransformConfiguration { ) => TransformConfiguration([], textTransformers); factory TransformConfiguration.forReplyForwardEmail() => TransformConfiguration.fromDomTransformers([ - if (PlatformInfo.isWeb) - const RemoveTooltipLinkTransformer(), const SignatureTransformer(), const RemoveCollapsedSignatureButtonTransformer(), const NormalizeLineHeightInStyleTransformer(), @@ -74,7 +71,7 @@ class TransformConfiguration { const RemoveScriptTransformer(), const BlockQuotedTransformer(), const BlockCodeTransformer(), - SanitizeHyperLinkTagInHtmlTransformer(useTooltip: true), + SanitizeHyperLinkTagInHtmlTransformer(), const ImageTransformer(), const AddLazyLoadingForBackgroundImageTransformer(), const RemoveCollapsedSignatureButtonTransformer(), @@ -89,8 +86,6 @@ class TransformConfiguration { ); factory TransformConfiguration.forPrintEmail() => TransformConfiguration.fromDomTransformers([ - if (PlatformInfo.isWeb) - const RemoveTooltipLinkTransformer(), const RemoveLazyLoadingForBackgroundImageTransformer(), const RemoveLazyLoadingImageTransformer(), const RemoveCollapsedSignatureButtonTransformer(), @@ -103,7 +98,7 @@ class TransformConfiguration { const RemoveScriptTransformer(), const BlockQuotedTransformer(), const BlockCodeTransformer(), - SanitizeHyperLinkTagInHtmlTransformer(useTooltip: PlatformInfo.isWeb), + SanitizeHyperLinkTagInHtmlTransformer(), const ImageTransformer(), ], ); @@ -139,7 +134,7 @@ class TransformConfiguration { const RemoveScriptTransformer(), const BlockQuotedTransformer(), const BlockCodeTransformer(), - SanitizeHyperLinkTagInHtmlTransformer(useTooltip: PlatformInfo.isWeb), + SanitizeHyperLinkTagInHtmlTransformer(), const ImageTransformer(), const AddLazyLoadingForBackgroundImageTransformer(), const RemoveCollapsedSignatureButtonTransformer(), diff --git a/core/lib/presentation/views/html_viewer/html_content_viewer_on_web_widget.dart b/core/lib/presentation/views/html_viewer/html_content_viewer_on_web_widget.dart index 05ddb4764..2b0f44468 100644 --- a/core/lib/presentation/views/html_viewer/html_content_viewer_on_web_widget.dart +++ b/core/lib/presentation/views/html_viewer/html_content_viewer_on_web_widget.dart @@ -5,6 +5,7 @@ import 'dart:math' as math; import 'package:core/presentation/constants/constants_ui.dart'; import 'package:core/presentation/extensions/color_extension.dart'; import 'package:core/presentation/views/shortcut/key_shortcut.dart'; +import 'package:core/presentation/views/tooltip/iframe_tooltip_overlay.dart'; import 'package:core/utils/app_logger.dart'; import 'package:core/utils/html/html_interaction.dart'; import 'package:core/utils/html/html_template.dart'; @@ -51,6 +52,7 @@ class HtmlContentViewerOnWeb extends StatefulWidget { final double? viewMaxHeight; final bool autoAdjustHeight; final bool useLinkTooltipOverlay; + final IframeTooltipOptions? iframeTooltipOptions; const HtmlContentViewerOnWeb({ Key? key, @@ -77,6 +79,7 @@ class HtmlContentViewerOnWeb extends StatefulWidget { this.viewMaxHeight, this.onIFrameKeyboardShortcutAction, this.onIFrameClickAction, + this.iframeTooltipOptions, }) : super(key: key); @override @@ -103,12 +106,19 @@ class _HtmlContentViewerOnWebState extends State static const String onScrollChangedEvent = 'onScrollChanged'; static const String onScrollEndEvent = 'onScrollEnd'; + IframeTooltipOverlay? _tooltipOverlay; + @override void initState() { super.initState(); _actualHeight = widget.heightContent; _actualWidth = widget.widthContent; minHeight = widget.htmlContentMinHeight; + if (PlatformInfo.isWebDesktop) { + _tooltipOverlay = IframeTooltipOverlay( + options: widget.iframeTooltipOptions ?? const IframeTooltipOptions(), + ); + } _setUpWeb(); _onMessageSubscription = html.window.onMessage.listen(_handleMessageEvent); } @@ -133,6 +143,12 @@ class _HtmlContentViewerOnWebState extends State } else if (_isIframeClickEventTriggered(type)) { _handleOnIFrameClickEvent(data); return; + } else if (_isIframeLinkHoverEventTriggered(type)) { + _handleOnIFrameLinkHoverEvent(data); + return; + } else if (_isIframeLinkOutEventTriggered(type)) { + _handleOnIFrameLinkOutEvent(data); + return; } if (data['message'] == iframeOnLoadMessage) { @@ -311,6 +327,46 @@ class _HtmlContentViewerOnWebState extends State } } + bool _isIframeLinkHoverEventTriggered(String? type) { + return type?.contains('toDart: iframeLinkHover') == true; + } + + bool _isIframeLinkOutEventTriggered(String? type) { + return type?.contains('toDart: iframeLinkOut') == true; + } + + void _handleOnIFrameLinkHoverEvent(dynamic data) { + try { + log('$runtimeType::_handleOnIFrameLinkHoverEvent: $data'); + final url = data['url'] ?? ''; + final rectData = data['rect']; + + if (rectData != null) { + final rect = Rect.fromLTWH( + rectData['x']?.toDouble() ?? 0, + rectData['y']?.toDouble() ?? 0, + rectData['width']?.toDouble() ?? 0, + rectData['height']?.toDouble() ?? 0, + ); + + if (mounted) { + _tooltipOverlay?.show(context, url, rect); + } + } + } catch (e) { + logError('$runtimeType::_handleOnIFrameLinkHoverEvent: Exception = $e'); + } + } + + void _handleOnIFrameLinkOutEvent(dynamic data) { + try { + log('$runtimeType::_handleOnIFrameLinkOutEvent: $data'); + _tooltipOverlay?.hide(); + } catch (e) { + logError('$runtimeType::_handleOnIFrameLinkOutEvent: Exception = $e'); + } + } + @override void didUpdateWidget(covariant HtmlContentViewerOnWeb oldWidget) { super.didUpdateWidget(oldWidget); @@ -430,7 +486,6 @@ class _HtmlContentViewerOnWebState extends State : content; final combinedCss = [ - HtmlTemplate.tooltipLinkCss, if (widget.enableQuoteToggle) HtmlUtils.quoteToggleStyle, if (widget.disableScrolling) HtmlTemplate.disableScrollingStyleCSS, ].join(); @@ -456,6 +511,8 @@ class _HtmlContentViewerOnWebState extends State HtmlInteraction.scriptHandleIframeKeyboardListener(_createdViewId), if (widget.useLinkTooltipOverlay) HtmlInteraction.scriptsHandleIframeClickListener(_createdViewId), + if (PlatformInfo.isWebDesktop) + HtmlInteraction.scriptsHandleIframeLinkHoverListener(_createdViewId), ].join(); final htmlTemplate = HtmlUtils.generateHtmlDocument( @@ -567,6 +624,10 @@ class _HtmlContentViewerOnWebState extends State void dispose() { _htmlData = null; _onMessageSubscription.cancel(); + if (PlatformInfo.isWebDesktop) { + _tooltipOverlay?.hide(); + _tooltipOverlay = null; + } super.dispose(); } diff --git a/core/lib/presentation/views/tooltip/iframe_tooltip_overlay.dart b/core/lib/presentation/views/tooltip/iframe_tooltip_overlay.dart new file mode 100644 index 000000000..5b904e781 --- /dev/null +++ b/core/lib/presentation/views/tooltip/iframe_tooltip_overlay.dart @@ -0,0 +1,161 @@ +import 'package:flutter/material.dart'; +import 'package:pointer_interceptor/pointer_interceptor.dart'; + +class IframeTooltipOptions { + final double tooltipBaseWidth; + final double tooltipHeight; + final double tooltipHorizontalMargin; + final double tooltipMarginTop; + final TextStyle? tooltipTextStyle; + + const IframeTooltipOptions({ + this.tooltipBaseWidth = 400, + this.tooltipHeight = 28, + this.tooltipHorizontalMargin = 12, + this.tooltipMarginTop = 4, + this.tooltipTextStyle, + }); +} + +class IframeTooltipOverlay { + OverlayEntry? _entry; + + final IframeTooltipOptions options; + + IframeTooltipOverlay({ + this.options = const IframeTooltipOptions(), + }); + + void show( + BuildContext context, + String url, + Rect rect, + ) { + if (_entry != null) { + hide(); + Future.microtask(() { + if (context.mounted) { + show(context, url, rect); + } + }); + return; + } + + final overlay = Overlay.maybeOf(context); + if (overlay == null) return; + + Offset containerOffset = Offset.zero; + final renderObject = context.findRenderObject(); + if (renderObject is RenderBox) { + containerOffset = renderObject.localToGlobal(Offset.zero); + } + + final adjustedRect = rect.shift(containerOffset); + + final viewportWidth = MediaQuery.sizeOf(context).width.toDouble(); + final viewportHeight = MediaQuery.sizeOf(context).height.toDouble(); + + final tooltipWidth = viewportWidth < options.tooltipBaseWidth + ? viewportWidth - options.tooltipHorizontalMargin * 2 + : options.tooltipBaseWidth; + + final showAbove = (adjustedRect.bottom + + options.tooltipHeight + + options.tooltipMarginTop) > + viewportHeight; + + final tooltipTop = showAbove + ? adjustedRect.top - options.tooltipHeight - options.tooltipMarginTop + : adjustedRect.bottom + options.tooltipMarginTop; + + double tooltipLeft = adjustedRect.left; + + if (tooltipLeft + tooltipWidth > viewportWidth) { + tooltipLeft = + viewportWidth - tooltipWidth - options.tooltipHorizontalMargin; + } + + if (tooltipLeft < options.tooltipHorizontalMargin) { + tooltipLeft = options.tooltipHorizontalMargin; + } + + _entry = OverlayEntry( + builder: (_) { + return TweenAnimationBuilder( + tween: Tween(begin: 0, end: 1), + duration: const Duration(milliseconds: 130), + curve: Curves.easeOut, + builder: (context, opacity, child) { + final slide = (showAbove ? -1 : 1) * (1 - opacity) * 8; + + return Opacity( + opacity: opacity, + child: Transform.translate( + offset: Offset(0, slide), + child: child, + ), + ); + }, + child: Stack( + children: [ + Positioned.fill( + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: hide, + ), + ), + PositionedDirectional( + start: tooltipLeft, + top: tooltipTop, + child: PointerInterceptor( + child: Material( + borderRadius: const BorderRadius.all(Radius.circular(6)), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 5, + ), + constraints: BoxConstraints( + maxWidth: tooltipWidth, + minHeight: options.tooltipHeight, + ), + decoration: BoxDecoration( + color: Colors.black, + borderRadius: const BorderRadius.all( + Radius.circular(6), + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.15), + blurRadius: 20, + ), + ], + ), + child: Text( + url, + style: options.tooltipTextStyle ?? + const TextStyle( + color: Colors.white, + fontSize: 13, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ), + ), + ), + ], + ), + ); + }, + ); + + overlay.insert(_entry!); + } + + void hide() { + _entry?.remove(); + _entry = null; + } +} diff --git a/core/lib/utils/html/html_interaction.dart b/core/lib/utils/html/html_interaction.dart index 4b1b78f34..8de99b2d7 100644 --- a/core/lib/utils/html/html_interaction.dart +++ b/core/lib/utils/html/html_interaction.dart @@ -419,4 +419,39 @@ class HtmlInteraction { }); '''; + + static String scriptsHandleIframeLinkHoverListener(String viewId) => ''' + + '''; } \ No newline at end of file diff --git a/core/lib/utils/html/html_template.dart b/core/lib/utils/html/html_template.dart index fd1da0ebb..03733628e 100644 --- a/core/lib/utils/html/html_template.dart +++ b/core/lib/utils/html/html_template.dart @@ -2,27 +2,6 @@ import 'package:flutter/material.dart'; class HtmlTemplate { - static const String nameClassToolTip = 'tmail-tooltip'; - static const String tooltipLinkCss = ''' - .$nameClassToolTip .tooltiptext { - visibility: hidden; - max-width: 400px; - background-color: black; - color: #fff; - text-align: center; - border-radius: 6px; - padding: 5px 8px 5px 8px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - position: absolute; - z-index: 1; - } - .$nameClassToolTip:hover .tooltiptext { - visibility: visible; - } - '''; - static const String printDocumentCssStyle = '''