From 09b7fe539eebddd868f65b31f912937d6c1108ca Mon Sep 17 00:00:00 2001 From: PhatPhuckDave Date: Wed, 19 Mar 2025 00:28:59 +0100 Subject: [PATCH] Fix up markdown rendering to be scrollable --- lib/bbcode_converter.dart | 86 ++++++++--- lib/markdown_tooltip.dart | 314 +++++++++++++++++++++++++++----------- 2 files changed, 296 insertions(+), 104 deletions(-) diff --git a/lib/bbcode_converter.dart b/lib/bbcode_converter.dart index 54bc6ff..7969067 100644 --- a/lib/bbcode_converter.dart +++ b/lib/bbcode_converter.dart @@ -1,11 +1,22 @@ /// Utility class to convert BBCode to Markdown for RimWorld mod descriptions +import 'dart:math' as math; + class BBCodeConverter { /// Converts BBCode formatted text to Markdown format static String toMarkdown(String bbcode) { if (bbcode.isEmpty) return ''; - // First, normalize line endings - String result = bbcode.replaceAll('\r\n', '\n'); + // First, normalize line endings and escape any literal backslashes + String result = bbcode.replaceAll('\r\n', '\n').replaceAll('\\', '\\\\'); + + // Ensure paragraphs - double newlines between paragraphs + // First normalize any consecutive newlines to a single one + result = result.replaceAll(RegExp(r'\n+'), '\n'); + + // Then add empty line after each paragraph where needed + result = result.replaceAll('.\n', '.\n\n'); + result = result.replaceAll('!\n', '!\n\n'); + result = result.replaceAll('?\n', '?\n\n'); // Fix unclosed tags - RimWorld descriptions often have unclosed tags final List tagTypes = ['b', 'i', 'color', 'size', 'url', 'code', 'quote']; @@ -42,30 +53,29 @@ class BBCodeConverter { // Italic result = result.replaceAll('[i]', '_').replaceAll('[/i]', '_'); - // Headers + // Headers - ensure they start on their own line result = result.replaceAllMapped( RegExp(r'\[h1\](.*?)\[/h1\]', dotAll: true), - (match) => '# ${match.group(1)}' + (match) => '\n\n# ${match.group(1)?.trim()}\n\n' ); result = result.replaceAllMapped( RegExp(r'\[h2\](.*?)\[/h2\]', dotAll: true), - (match) => '## ${match.group(1)}' + (match) => '\n\n## ${match.group(1)?.trim()}\n\n' ); result = result.replaceAllMapped( RegExp(r'\[h3\](.*?)\[/h3\]', dotAll: true), - (match) => '### ${match.group(1)}' + (match) => '\n\n### ${match.group(1)?.trim()}\n\n' ); // Lists - handle nested lists too result = result.replaceAll('[list]', '\n').replaceAll('[/list]', '\n'); // Handle list items - giving them proper indentation - int listLevel = 0; result = result.replaceAllMapped( RegExp(r'\[\*\](.*?)(?=\[\*\]|\[/list\]|$)', dotAll: true), (match) { final content = match.group(1)?.trim() ?? ''; - return '* $content\n'; + return '\n* $content\n'; } ); @@ -102,35 +112,64 @@ class BBCodeConverter { final tableContent = tableMatch.group(1) ?? ''; final rows = RegExp(r'\[tr\](.*?)\[/tr\]', dotAll: true).allMatches(tableContent); - final markdownTable = StringBuffer(); + // Only process tables that have rows + if (rows.isEmpty) { + result = result.replaceFirst(tableMatch.group(0)!, ''); + continue; + } + + final markdownTable = StringBuffer('\n'); var isFirstRow = true; + // First determine the number of columns by examining all rows + int maxColumns = 0; + for (final rowMatch in rows) { + final rowContent = rowMatch.group(1) ?? ''; + final cellCount = RegExp(r'\[td\]').allMatches(rowContent).length; + maxColumns = math.max(maxColumns, cellCount); + } + + // Ensure we have at least 1 column + maxColumns = math.max(1, maxColumns); + for (final rowMatch in rows) { final rowContent = rowMatch.group(1) ?? ''; final cells = RegExp(r'\[td\](.*?)\[/td\]', dotAll: true).allMatches(rowContent); if (cells.isEmpty) continue; - final rowBuffer = StringBuffer('|'); + final rowBuffer = StringBuffer('| '); + int cellsAdded = 0; for (final cellMatch in cells) { final cellContent = cellMatch.group(1)?.trim() ?? ''; // Clean up any newlines inside cell content final cleanCell = cellContent.replaceAll('\n', ' ').trim(); - rowBuffer.write(' $cleanCell |'); + rowBuffer.write('$cleanCell | '); + cellsAdded++; + } + + // Add empty cells if needed to maintain table structure + while (cellsAdded < maxColumns) { + rowBuffer.write(' | '); + cellsAdded++; } markdownTable.writeln(rowBuffer.toString()); // Add header separator after first row if (isFirstRow) { - final cellCount = RegExp(r'\[td\]').allMatches(rowContent).length; - markdownTable.writeln('|${' --- |' * cellCount}'); + final headerRow = StringBuffer('| '); + for (int i = 0; i < maxColumns; i++) { + headerRow.write('--- | '); + } + markdownTable.writeln(headerRow.toString()); isFirstRow = false; } } - result = result.replaceFirst(tableMatch.group(0)!, '\n${markdownTable.toString()}\n'); + markdownTable.write('\n'); + result = result.replaceFirst(tableMatch.group(0)!, markdownTable.toString()); } } @@ -149,7 +188,7 @@ class BBCodeConverter { (match) { final content = match.group(1)?.trim() ?? ''; if (content.isEmpty) return ''; - return '\n> ${content.replaceAll('\n', '\n> ')}\n'; + return '\n> ${content.replaceAll('\n', '\n> ')}\n\n'; } ); @@ -163,12 +202,25 @@ class BBCodeConverter { // [h1] without closing tag is common result = result.replaceAllMapped( RegExp(r'\[h1\]([^\[]+)'), - (match) => '# ${match.group(1)}\n' + (match) => '\n\n# ${match.group(1)?.trim()}\n\n' ); - // Replace multiple newlines with at most two newlines + // Convert simple newlines to double newlines for proper markdown rendering + // But avoid doing this for lines that are already marked up as headings, lists, or tables + result = result.replaceAllMapped( + RegExp(r'([^\n#*>|])\n([^\n#*>|-])'), + (match) => '${match.group(1)}\n\n${match.group(2)}' + ); + + // Normalize multiple spaces + result = result.replaceAll(RegExp(r' {2,}'), ' '); + + // Remove excessive newlines (more than 2 consecutive) result = result.replaceAll(RegExp(r'\n{3,}'), '\n\n'); + // Ensure document starts and ends cleanly + result = result.trim(); + return result; } } \ No newline at end of file diff --git a/lib/markdown_tooltip.dart b/lib/markdown_tooltip.dart index a3a624d..e5461cb 100644 --- a/lib/markdown_tooltip.dart +++ b/lib/markdown_tooltip.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter_markdown/flutter_markdown.dart'; import 'package:rimworld_modman/bbcode_converter.dart'; +import 'dart:math' as math; /// A custom tooltip widget that can properly render mod descriptions in BBCode format. class MarkdownTooltip extends StatefulWidget { @@ -25,7 +26,7 @@ class MarkdownTooltip extends StatefulWidget { super.key, required this.markdownContent, required this.child, - this.preferredWidth = 500.0, + this.preferredWidth = 800.0, this.maxHeight = 500.0, this.styleSheet, }); @@ -39,12 +40,15 @@ class _MarkdownTooltipState extends State { final LayerLink _layerLink = LayerLink(); bool _isTooltipVisible = false; bool _isMouseInside = false; + bool _isMouseInsideTooltip = false; bool _isPointerListenerActive = false; + final ScrollController _scrollController = ScrollController(); @override void dispose() { _removeGlobalListener(); _hideTooltip(); + _scrollController.dispose(); super.dispose(); } @@ -154,15 +158,37 @@ class _MarkdownTooltipState extends State { leftPosition = screenSize.width - widget.preferredWidth - 10; // 10px padding from right edge } + // Calculate appropriate tooltip height based on content length + // Longer descriptions need more space + final contentLength = widget.markdownContent.length; + double dynamicHeight = widget.maxHeight; + if (contentLength < 500) { + dynamicHeight = 250.0; // Small description + } else if (contentLength < 2000) { + dynamicHeight = 350.0; // Medium description + } else { + dynamicHeight = widget.maxHeight; // Large description + } + + // Ensure dynamic height doesn't exceed screen height + dynamicHeight = math.min(dynamicHeight, screenSize.height * 0.7); + // If tooltip would go below screen bottom, show it above the widget instead - bool showAbove = topPosition + widget.maxHeight > screenSize.height - 10; + bool showAbove = topPosition + dynamicHeight > screenSize.height - 10; if (showAbove) { - topPosition = offset.dy - widget.maxHeight - 5; // 5px above the widget + topPosition = offset.dy - dynamicHeight - 5; // 5px above the widget + + // If going above would put it offscreen at the top, prefer below but with reduced height + if (topPosition < 10) { + showAbove = false; + topPosition = offset.dy + size.height + 5.0; + dynamicHeight = math.min(dynamicHeight, screenSize.height - topPosition - 20); + } } // Create follower offset based on calculated position final Offset followerOffset = showAbove - ? Offset(size.width * 0.5 - widget.preferredWidth * 0.5, -widget.maxHeight - 5) + ? Offset(size.width * 0.5 - widget.preferredWidth * 0.5, -dynamicHeight - 5) : Offset(size.width * 0.5 - widget.preferredWidth * 0.5, size.height + 5.0); // Create a custom style sheet based on the dark theme @@ -236,89 +262,199 @@ class _MarkdownTooltipState extends State { left: leftPosition, top: topPosition, width: widget.preferredWidth, - child: CompositedTransformFollower( - link: _layerLink, - offset: followerOffset, - child: Material( - elevation: 8.0, - borderRadius: BorderRadius.circular(8.0), - color: Colors.transparent, - child: Container( - constraints: BoxConstraints( - maxHeight: widget.maxHeight, - ), - decoration: BoxDecoration( - color: const Color(0xFF2A3440), // Match app's dark theme card color - borderRadius: BorderRadius.circular(8.0), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.3), - blurRadius: 10, - offset: const Offset(0, 5), - ), - ], - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(8.0), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Header - Container( - padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), - width: double.infinity, - color: const Color(0xFF3D4A59), // Match app's primary color - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const Text( - 'Mod Description', - style: TextStyle( - color: Colors.white, - fontWeight: FontWeight.bold, - fontSize: 14.0, - ), - ), - InkWell( - onTap: () { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - _hideTooltip(); - _removeGlobalListener(); - } - }); - }, - child: const Padding( - padding: EdgeInsets.all(2.0), - child: Icon( - Icons.close, - color: Colors.white, - size: 16.0, - ), - ), - ), - ], + child: MouseRegion( + onEnter: (_) { + setState(() { + _isMouseInsideTooltip = true; + }); + }, + onExit: (_) { + setState(() { + _isMouseInsideTooltip = false; + // Delay hiding to avoid flickering when moving between tooltip and trigger + Future.delayed(const Duration(milliseconds: 100), () { + if (!_isMouseInside && !_isMouseInsideTooltip && mounted) { + _hideTooltip(); + _removeGlobalListener(); + } + }); + }); + }, + child: CompositedTransformFollower( + link: _layerLink, + offset: followerOffset, + child: Material( + elevation: 8.0, + borderRadius: BorderRadius.circular(8.0), + color: Colors.transparent, + child: ConstrainedBox( + constraints: BoxConstraints( + maxHeight: dynamicHeight, + maxWidth: widget.preferredWidth, + minHeight: 100.0, // Ensure a minimum height to show content + ), + child: Container( + decoration: BoxDecoration( + color: const Color(0xFF2A3440), // Match app's dark theme card color + borderRadius: BorderRadius.circular(8.0), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.3), + blurRadius: 10, + offset: const Offset(0, 5), ), - ), - // Markdown content - Flexible( - child: SingleChildScrollView( - child: Padding( - padding: const EdgeInsets.all(16.0), - child: MarkdownBody( - data: widget.markdownContent.length > 5000 - ? BBCodeConverter.toMarkdown('${widget.markdownContent.substring(0, 5000)}...\n\n*[Description truncated due to length]*') - : BBCodeConverter.toMarkdown(widget.markdownContent), - styleSheet: effectiveStyleSheet, - shrinkWrap: true, - softLineBreak: true, - selectable: true, + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(8.0), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header + Container( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), + width: double.infinity, + color: const Color(0xFF3D4A59), // Match app's primary color + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'Mod Description', + style: 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: () { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _hideTooltip(); + _removeGlobalListener(); + } + }); + }, + child: const Padding( + padding: EdgeInsets.all(2.0), + child: Icon( + Icons.close, + color: Colors.white, + size: 16.0, + ), + ), + ), + ], + ), + ], ), ), - ), + // Markdown content + Flexible( + child: Stack( + children: [ + // Scrollable content + SingleChildScrollView( + controller: _scrollController, + physics: const AlwaysScrollableScrollPhysics(), + scrollDirection: Axis.vertical, + child: Padding( + padding: const EdgeInsets.all(16.0), + child: MarkdownBody( + data: widget.markdownContent.length > 7000 + ? BBCodeConverter.toMarkdown('${widget.markdownContent.substring(0, 7000)}...\n\n*[Description truncated due to length]*') + : BBCodeConverter.toMarkdown(widget.markdownContent), + styleSheet: effectiveStyleSheet, + shrinkWrap: true, + softLineBreak: true, + selectable: true, + onTapLink: (text, href, title) { + if (href != null) { + debugPrint('Link tapped: $href'); + // Here you could add code to open the link in a browser + } + }, + ), + ), + ), + + // Scroll indicator (fades out when scrolling begins) + Positioned( + bottom: 10, + right: 10, + child: StreamBuilder( + stream: Stream.periodic(const Duration(milliseconds: 200), (_) { + if (!_scrollController.hasClients) return 0.0; + + // Check if content is scrollable + final isScrollable = _scrollController.position.maxScrollExtent > 0; + + // If not scrollable, don't show the indicator + if (!isScrollable) return -1.0; + + return _scrollController.offset; + }), + builder: (context, snapshot) { + // Check for scrollability + final isScrollable = snapshot.hasData && snapshot.data! >= 0; + + // Check for already scrolled state + final isScrolled = snapshot.hasData && snapshot.data! > 20.0; + + // Don't show indicator at all if content is not scrollable + if (!isScrollable) return const SizedBox.shrink(); + + return AnimatedOpacity( + opacity: isScrolled ? 0.0 : 0.7, + duration: const Duration(milliseconds: 300), + child: Container( + padding: const EdgeInsets.all(8.0), + decoration: BoxDecoration( + color: Colors.black54, + borderRadius: BorderRadius.circular(20), + ), + child: const Icon( + Icons.keyboard_arrow_down, + color: Colors.white, + size: 16, + ), + ), + ); + }, + ), + ), + ], + ), + ), + ], ), - ], + ), ), ), ), @@ -346,19 +482,23 @@ class _MarkdownTooltipState extends State { }, child: MouseRegion( onEnter: (_) { - _isMouseInside = true; + setState(() { + _isMouseInside = true; + }); // Delay showing tooltip slightly to avoid accidental triggers Future.delayed(const Duration(milliseconds: 100), () { - if (mounted && _isMouseInside && !_isTooltipVisible) { + if (mounted && (_isMouseInside || _isMouseInsideTooltip) && !_isTooltipVisible) { _showTooltip(); } }); }, onExit: (_) { - _isMouseInside = false; + setState(() { + _isMouseInside = false; + }); // Delay hiding tooltip slightly to allow moving to the tooltip itself Future.delayed(const Duration(milliseconds: 100), () { - if (mounted && !_isMouseInside && _isTooltipVisible) { + if (mounted && !_isMouseInside && !_isMouseInsideTooltip && _isTooltipVisible) { _hideTooltip(); _removeGlobalListener(); }