345 lines
14 KiB
Dart
345 lines
14 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_html/flutter_html.dart';
|
|
import 'package:url_launcher/url_launcher.dart';
|
|
import '../format_converter.dart';
|
|
|
|
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<HtmlTooltip> createState() => _HtmlTooltipState();
|
|
}
|
|
|
|
class _HtmlTooltipState extends State<HtmlTooltip> {
|
|
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<void> _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,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|