Jesus they're not BBCode... They're html and bbcode and markdown
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
This commit is contained in:
327
lib/components/html_tooltip.dart
Normal file
327
lib/components/html_tooltip.dart
Normal file
@@ -0,0 +1,327 @@
|
||||
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 = 300.0,
|
||||
this.maxHeight = 400.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;
|
||||
|
||||
// Calculate appropriate width and position
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
double tooltipWidth = widget.maxWidth;
|
||||
|
||||
// Adjust tooltip width for longer descriptions
|
||||
if (widget.content.length > 1000) {
|
||||
tooltipWidth = math.min(screenWidth * 0.5, 500.0);
|
||||
}
|
||||
|
||||
_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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user