TF-4152 Fix tooltip stay out of screen in Email View

This commit is contained in:
dab246
2025-11-18 10:15:51 +07:00
committed by Dat H. Pham
parent 9ecf42989d
commit aba3cbbe4c
12 changed files with 291 additions and 93 deletions
@@ -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<HtmlContentViewerOnWeb>
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<HtmlContentViewerOnWeb>
} 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<HtmlContentViewerOnWeb>
}
}
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<HtmlContentViewerOnWeb>
: content;
final combinedCss = [
HtmlTemplate.tooltipLinkCss,
if (widget.enableQuoteToggle) HtmlUtils.quoteToggleStyle,
if (widget.disableScrolling) HtmlTemplate.disableScrollingStyleCSS,
].join();
@@ -456,6 +511,8 @@ class _HtmlContentViewerOnWebState extends State<HtmlContentViewerOnWeb>
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<HtmlContentViewerOnWeb>
void dispose() {
_htmlData = null;
_onMessageSubscription.cancel();
if (PlatformInfo.isWebDesktop) {
_tooltipOverlay?.hide();
_tooltipOverlay = null;
}
super.dispose();
}
@@ -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<double>(
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;
}
}