import 'package:flutter/material.dart'; import 'package:flutter_html/flutter_html.dart'; import 'package:url_launcher/url_launcher.dart'; import '../format_converter.dart'; import 'dart:math' as math; class HtmlTooltip extends StatefulWidget { final Widget child; final String content; final double maxWidth; final double maxHeight; final EdgeInsets padding; final Duration showDuration; final Duration fadeDuration; final Color backgroundColor; final Color textColor; final BorderRadius borderRadius; final bool preferBelow; final String? title; const HtmlTooltip({ super.key, required this.child, required this.content, this.maxWidth = 800.0, this.maxHeight = 800.0, this.padding = const EdgeInsets.all(8.0), this.showDuration = const Duration(milliseconds: 0), this.fadeDuration = const Duration(milliseconds: 200), this.backgroundColor = const Color(0xFF232323), this.textColor = Colors.white, this.borderRadius = const BorderRadius.all(Radius.circular(4.0)), this.preferBelow = true, this.title = 'Mod Description', }); @override State createState() => _HtmlTooltipState(); } class _HtmlTooltipState extends State { final LayerLink _layerLink = LayerLink(); OverlayEntry? _overlayEntry; bool _isTooltipVisible = false; bool _isMouseInside = false; bool _isMouseInsideTooltip = false; final ScrollController _scrollController = ScrollController(); @override void dispose() { _scrollController.dispose(); _hideTooltip(); super.dispose(); } // Launch a URL Future _launchUrl(String? urlString) async { if (urlString == null || urlString.isEmpty) return; final Uri url = Uri.parse(urlString); try { if (await canLaunchUrl(url)) { await launchUrl(url, mode: LaunchMode.externalApplication); } } catch (e) { debugPrint('Error launching URL: $e'); } } void _showTooltip(BuildContext context) { if (_overlayEntry != null) return; // Get render box of the trigger widget final RenderBox box = context.findRenderObject() as RenderBox; final Size childSize = box.size; // Use the specified maxWidth without adjusting based on content length final double tooltipWidth = widget.maxWidth; _overlayEntry = OverlayEntry( builder: (context) { return Positioned( width: tooltipWidth, child: CompositedTransformFollower( link: _layerLink, showWhenUnlinked: false, offset: Offset( (childSize.width / 2) - (tooltipWidth / 2), widget.preferBelow ? childSize.height + 5 : -5, ), child: Material( color: Colors.transparent, child: MouseRegion( onEnter: (_) { setState(() { _isMouseInsideTooltip = true; }); }, onExit: (_) { setState(() { _isMouseInsideTooltip = false; // Slight delay to prevent flickering Future.delayed(const Duration(milliseconds: 50), () { if (!_isMouseInside && !_isMouseInsideTooltip) { _hideTooltip(); } }); }); }, child: FadeTransition( opacity: const AlwaysStoppedAnimation(1.0), child: Container( constraints: BoxConstraints( maxWidth: tooltipWidth, maxHeight: widget.maxHeight, ), decoration: BoxDecoration( color: widget.backgroundColor, borderRadius: widget.borderRadius, boxShadow: [ BoxShadow( color: Colors.black.withAlpha( 77, ), // Equivalent to 0.3 opacity blurRadius: 10.0, spreadRadius: 0.0, ), ], ), child: ClipRRect( borderRadius: widget.borderRadius, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ // Header Container( color: const Color(0xFF3D4A59), padding: const EdgeInsets.symmetric( horizontal: 16.0, vertical: 8.0, ), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( widget.title ?? 'Description', style: const TextStyle( color: Colors.white, fontWeight: FontWeight.bold, fontSize: 14.0, ), ), Row( mainAxisSize: MainAxisSize.min, children: [ // Scroll to top button InkWell( onTap: () { if (_scrollController.hasClients) { _scrollController.animateTo( 0.0, duration: const Duration( milliseconds: 300, ), curve: Curves.easeOut, ); } }, child: const Padding( padding: EdgeInsets.all(2.0), child: Icon( Icons.arrow_upward, color: Colors.white, size: 16.0, ), ), ), const SizedBox(width: 4.0), // Close button InkWell( onTap: () { _hideTooltip(); }, child: const Padding( padding: EdgeInsets.all(2.0), child: Icon( Icons.close, color: Colors.white, size: 16.0, ), ), ), ], ), ], ), ), // Content Flexible( child: SingleChildScrollView( controller: _scrollController, child: Padding( padding: widget.padding, child: Html( data: FormatConverter.toHtml(widget.content), style: { "body": Style( color: widget.textColor, margin: Margins.zero, padding: HtmlPaddings.zero, fontSize: FontSize(14.0), ), "a": Style( color: Colors.lightBlue, textDecoration: TextDecoration.underline, ), "blockquote": Style( backgroundColor: Colors.grey.withAlpha( 26, ), // Approx 0.1 opacity border: Border( left: BorderSide( color: Colors.grey.withAlpha( 128, ), // Approx 0.5 opacity width: 4.0, ), ), padding: HtmlPaddings.all(8.0), margin: Margins.only(left: 0, right: 0), ), "code": Style( backgroundColor: Colors.grey.withAlpha( 51, ), // Approx 0.2 opacity padding: HtmlPaddings.all(2.0), fontFamily: 'monospace', ), "pre": Style( backgroundColor: Colors.grey.withAlpha( 51, ), // Approx 0.2 opacity padding: HtmlPaddings.all(8.0), fontFamily: 'monospace', margin: Margins.only( bottom: 8.0, top: 8.0, ), ), "table": Style( border: Border.all(color: Colors.grey), backgroundColor: Colors.transparent, ), "td": Style( border: Border.all( color: Colors.grey.withAlpha(128), ), // Approx 0.5 opacity padding: HtmlPaddings.all(4.0), ), "th": Style( border: Border.all( color: Colors.grey.withAlpha(128), ), // Approx 0.5 opacity padding: HtmlPaddings.all(4.0), backgroundColor: Colors.grey.withAlpha( 51, ), // Approx 0.2 opacity ), }, onAnchorTap: (url, _, __) { _launchUrl(url); }, ), ), ), ), ], ), ), ), ), ), ), ), ); }, ); // Use a check for mounted before inserting the overlay if (mounted) { Overlay.of(context).insert(_overlayEntry!); _isTooltipVisible = true; } } void _hideTooltip() { _overlayEntry?.remove(); _overlayEntry = null; _isTooltipVisible = false; } @override Widget build(BuildContext context) { return CompositedTransformTarget( link: _layerLink, child: MouseRegion( onEnter: (_) { setState(() { _isMouseInside = true; // Show tooltip after a brief delay to prevent accidental triggers Future.delayed(const Duration(milliseconds: 50), () { if (mounted && _isMouseInside && !_isTooltipVisible) { _showTooltip(context); } }); }); }, onExit: (_) { setState(() { _isMouseInside = false; // Slight delay to prevent flickering Future.delayed(const Duration(milliseconds: 50), () { if (mounted && !_isMouseInside && !_isMouseInsideTooltip) { _hideTooltip(); } }); }); }, child: GestureDetector( onTap: () { // Toggle tooltip for touch devices if (_isTooltipVisible) { _hideTooltip(); } else { _showTooltip(context); } }, child: widget.child, ), ), ); } }