diff --git a/lib/bbcode_converter.dart b/lib/bbcode_converter.dart deleted file mode 100644 index b166ddb..0000000 --- a/lib/bbcode_converter.dart +++ /dev/null @@ -1,228 +0,0 @@ -/// Utility class to convert BBCode to Markdown for RimWorld mod descriptions -library bbcode_converter; - -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 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']; - for (final tag in tagTypes) { - final openCount = '[$tag'.allMatches(result).length; - final closeCount = '[/$tag]'.allMatches(result).length; - if (openCount > closeCount) { - result = result + '[/$tag]' * (openCount - closeCount); - } - } - - // URLs - // [url=http://example.com]text[/url] -> [text](http://example.com) - result = RegExp(r'\[url=([^\]]+)\](.*?)\[/url\]', dotAll: true) - .allMatches(result) - .fold(result, (prev, match) { - final url = match.group(1); - final text = match.group(2); - return prev.replaceFirst( - match.group(0)!, - '[$text]($url)' - ); - }); - - // Simple URL [url]http://example.com[/url] -> - result = result.replaceAllMapped( - RegExp(r'\[url\](.*?)\[/url\]', dotAll: true), - (match) => '<${match.group(1)}>' - ); - - // Bold - result = result.replaceAll('[b]', '**').replaceAll('[/b]', '**'); - - // Italic - result = result.replaceAll('[i]', '_').replaceAll('[/i]', '_'); - - // Headers - ensure they start on their own line - result = result.replaceAllMapped( - RegExp(r'\[h1\](.*?)\[/h1\]', dotAll: true), - (match) => '\n\n# ${match.group(1)?.trim()}\n\n' - ); - result = result.replaceAllMapped( - RegExp(r'\[h2\](.*?)\[/h2\]', dotAll: true), - (match) => '\n\n## ${match.group(1)?.trim()}\n\n' - ); - result = result.replaceAllMapped( - RegExp(r'\[h3\](.*?)\[/h3\]', dotAll: true), - (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 - result = result.replaceAllMapped( - RegExp(r'\[\*\](.*?)(?=\[\*\]|\[/list\]|$)', dotAll: true), - (match) { - final content = match.group(1)?.trim() ?? ''; - return '\n* $content\n'; - } - ); - - // Color - convert to bold since Markdown doesn't support color - result = result.replaceAllMapped( - RegExp(r'\[color=([^\]]+)\](.*?)\[/color\]', dotAll: true), - (match) { - final content = match.group(2) ?? ''; - if (content.trim().isEmpty) return ''; - return '**${match.group(2)}**'; - } - ); - - // Images - // [img]url[/img] -> ![Image](url) - result = result.replaceAllMapped( - RegExp(r'\[img\](.*?)\[/img\]', dotAll: true), - (match) => '![Image](${match.group(1)})' - ); - - // Image with size [img width=300]url[/img] -> ![Image](url) - result = result.replaceAllMapped( - RegExp(r'\[img[^\]]*width=(\d+)[^\]]*\](.*?)\[/img\]', dotAll: true), - (match) => '![Image](${match.group(2)})' - ); - - // Tables - convert tables to markdown tables - if (result.contains('[table]')) { - // Process tables - final tableRegex = RegExp(r'\[table\](.*?)\[/table\]', dotAll: true); - final tables = tableRegex.allMatches(result); - - for (final tableMatch in tables) { - final tableContent = tableMatch.group(1) ?? ''; - final rows = RegExp(r'\[tr\](.*?)\[/tr\]', dotAll: true).allMatches(tableContent); - - // 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('| '); - - 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 | '); - 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 headerRow = StringBuffer('| '); - for (int i = 0; i < maxColumns; i++) { - headerRow.write('--- | '); - } - markdownTable.writeln(headerRow.toString()); - isFirstRow = false; - } - } - - markdownTable.write('\n'); - result = result.replaceFirst(tableMatch.group(0)!, markdownTable.toString()); - } - } - - // Size - remove since Markdown doesn't directly support font size - result = result.replaceAllMapped( - RegExp(r'\[size=[^\]]+\](.*?)\[/size\]', dotAll: true), - (match) => match.group(1) ?? '' - ); - - // Code - result = result.replaceAll('[code]', '\n```\n').replaceAll('[/code]', '\n```\n'); - - // Quote - result = result.replaceAllMapped( - RegExp(r'\[quote\](.*?)\[/quote\]', dotAll: true), - (match) { - final content = match.group(1)?.trim() ?? ''; - if (content.isEmpty) return ''; - return '\n> ${content.replaceAll('\n', '\n> ')}\n\n'; - } - ); - - // Handle any remaining custom BBCode tags - just remove them - result = result.replaceAllMapped( - RegExp(r'\[([a-zA-Z0-9_]+)(?:=[^\]]+)?\](.*?)\[/\1\]', dotAll: true), - (match) => match.group(2) ?? '' - ); - - // Handle RimWorld-specific patterns - // [h1] without closing tag is common - result = result.replaceAllMapped( - RegExp(r'\[h1\]([^\[]+)'), - (match) => '\n\n# ${match.group(1)?.trim()}\n\n' - ); - - // 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/components/html_tooltip.dart b/lib/components/html_tooltip.dart index 802b53f..65f159c 100644 --- a/lib/components/html_tooltip.dart +++ b/lib/components/html_tooltip.dart @@ -22,8 +22,8 @@ class HtmlTooltip extends StatefulWidget { super.key, required this.child, required this.content, - this.maxWidth = 300.0, - this.maxHeight = 400.0, + 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), @@ -45,7 +45,7 @@ class _HtmlTooltipState extends State { bool _isMouseInside = false; bool _isMouseInsideTooltip = false; final ScrollController _scrollController = ScrollController(); - + @override void dispose() { _scrollController.dispose(); @@ -56,7 +56,7 @@ class _HtmlTooltipState extends State { // 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)) { @@ -69,20 +69,14 @@ class _HtmlTooltipState extends State { 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); - } - + + // Use the specified maxWidth without adjusting based on content length + final double tooltipWidth = widget.maxWidth; + _overlayEntry = OverlayEntry( builder: (context) { return Positioned( @@ -125,7 +119,9 @@ class _HtmlTooltipState extends State { borderRadius: widget.borderRadius, boxShadow: [ BoxShadow( - color: Colors.black.withAlpha(77), // Equivalent to 0.3 opacity + color: Colors.black.withAlpha( + 77, + ), // Equivalent to 0.3 opacity blurRadius: 10.0, spreadRadius: 0.0, ), @@ -140,7 +136,10 @@ class _HtmlTooltipState extends State { // Header Container( color: const Color(0xFF3D4A59), - padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), + padding: const EdgeInsets.symmetric( + horizontal: 16.0, + vertical: 8.0, + ), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ @@ -161,7 +160,9 @@ class _HtmlTooltipState extends State { if (_scrollController.hasClients) { _scrollController.animateTo( 0.0, - duration: const Duration(milliseconds: 300), + duration: const Duration( + milliseconds: 300, + ), curve: Curves.easeOut, ); } @@ -195,7 +196,7 @@ class _HtmlTooltipState extends State { ], ), ), - + // Content Flexible( child: SingleChildScrollView( @@ -216,10 +217,14 @@ class _HtmlTooltipState extends State { textDecoration: TextDecoration.underline, ), "blockquote": Style( - backgroundColor: Colors.grey.withAlpha(26), // Approx 0.1 opacity + backgroundColor: Colors.grey.withAlpha( + 26, + ), // Approx 0.1 opacity border: Border( left: BorderSide( - color: Colors.grey.withAlpha(128), // Approx 0.5 opacity + color: Colors.grey.withAlpha( + 128, + ), // Approx 0.5 opacity width: 4.0, ), ), @@ -227,28 +232,41 @@ class _HtmlTooltipState extends State { margin: Margins.only(left: 0, right: 0), ), "code": Style( - backgroundColor: Colors.grey.withAlpha(51), // Approx 0.2 opacity + 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 + 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), + 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 + 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 + 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 + backgroundColor: Colors.grey.withAlpha( + 51, + ), // Approx 0.2 opacity ), }, onAnchorTap: (url, _, __) { @@ -324,4 +342,4 @@ class _HtmlTooltipState extends State { ), ); } -} \ No newline at end of file +} diff --git a/lib/format_converter.dart b/lib/format_converter.dart index dae321c..b33523c 100644 --- a/lib/format_converter.dart +++ b/lib/format_converter.dart @@ -5,31 +5,44 @@ class FormatConverter { /// Converts mixed format text (BBCode, Markdown, HTML) to pure HTML static String toHtml(String content) { if (content.isEmpty) return ''; - + // First, normalize line endings and escape any literal backslashes that aren't already escaped String result = content.replaceAll('\r\n', '\n'); - + // Handle BBCode format result = _convertBBCodeToHtml(result); - + // Handle Markdown format result = _convertMarkdownToHtml(result); - + // Sanitize HTML result = _sanitizeHtml(result); - + // Wrap the final content in a container with styles - result = '
$result
'; - + result = + '
$result
'; + return result; } - + /// Converts BBCode to HTML static String _convertBBCodeToHtml(String bbcode) { String result = bbcode; - + // Fix unclosed tags - RimWorld descriptions often have unclosed BBCode tags - final List tagTypes = ['b', 'i', 'color', 'size', 'url', 'code', 'quote', 'list', 'table', 'tr', 'td']; + final List tagTypes = [ + 'b', + 'i', + 'color', + 'size', + 'url', + 'code', + 'quote', + 'list', + 'table', + 'tr', + 'td', + ]; for (final tag in tagTypes) { final openCount = '[${tag}'.allMatches(result).length; final closeCount = '[/$tag]'.allMatches(result).length; @@ -37,75 +50,88 @@ class FormatConverter { result = result + '[/$tag]' * (openCount - closeCount); } } - + // URLs // [url=http://example.com]text[/url] -> text - result = RegExp(r'\[url=([^\]]+)\](.*?)\[/url\]', dotAll: true) - .allMatches(result) - .fold(result, (prev, match) { - final url = match.group(1); - final text = match.group(2); - return prev.replaceFirst( - match.group(0)!, - '$text' - ); - }); - + result = RegExp( + r'\[url=([^\]]+)\](.*?)\[/url\]', + dotAll: true, + ).allMatches(result).fold(result, (prev, match) { + final url = match.group(1); + final text = match.group(2); + return prev.replaceFirst( + match.group(0)!, + '$text', + ); + }); + // Simple URL [url]http://example.com[/url] -> http://example.com result = result.replaceAllMapped( - RegExp(r'\[url\](.*?)\[/url\]', dotAll: true), - (match) => '${match.group(1)}' + RegExp(r'\[url\](.*?)\[/url\]', dotAll: true), + (match) => + '${match.group(1)}', ); - + // Bold - result = result.replaceAll('[b]', '').replaceAll('[/b]', ''); - + result = result + .replaceAll('[b]', '') + .replaceAll('[/b]', ''); + // Italic result = result.replaceAll('[i]', '').replaceAll('[/i]', ''); - + // Headers result = result.replaceAllMapped( - RegExp(r'\[h1\](.*?)\[/h1\]', dotAll: true), - (match) => '

${match.group(1)?.trim()}

' + RegExp(r'\[h1\](.*?)\[/h1\]', dotAll: true), + (match) => + '

${match.group(1)?.trim()}

', ); result = result.replaceAllMapped( - RegExp(r'\[h2\](.*?)\[/h2\]', dotAll: true), - (match) => '

${match.group(1)?.trim()}

' + RegExp(r'\[h2\](.*?)\[/h2\]', dotAll: true), + (match) => + '

${match.group(1)?.trim()}

', ); result = result.replaceAllMapped( - RegExp(r'\[h3\](.*?)\[/h3\]', dotAll: true), - (match) => '

${match.group(1)?.trim()}

' + RegExp(r'\[h3\](.*?)\[/h3\]', dotAll: true), + (match) => + '

${match.group(1)?.trim()}

', ); - + // Lists - result = result.replaceAll('[list]', '
    ').replaceAll('[/list]', '
'); - + result = result + .replaceAll( + '[list]', + '
    ', + ) + .replaceAll('[/list]', '
'); + // List items result = result.replaceAllMapped( - RegExp(r'\[\*\](.*?)(?=\[\*\]|\[/list\]|$)', dotAll: true), + RegExp(r'\[\*\](.*?)(?=\[\*\]|\[/list\]|$)', dotAll: true), (match) { final content = match.group(1)?.trim() ?? ''; - return '
  • $content
  • '; - } + return '
  • $content
  • '; + }, ); - + // Color result = result.replaceAllMapped( - RegExp(r'\[color=([^\]]+)\](.*?)\[/color\]', dotAll: true), + RegExp(r'\[color=([^\]]+)\](.*?)\[/color\]', dotAll: true), (match) { final color = match.group(1) ?? ''; final content = match.group(2) ?? ''; if (content.trim().isEmpty) return ''; return '$content'; - } + }, ); - + // Images result = result.replaceAllMapped( - RegExp(r'\[img\](.*?)\[/img\]', dotAll: true), - (match) => 'Image' + RegExp(r'\[img\](.*?)\[/img\]', dotAll: true), + (match) => + 'Image', ); - + // Image with size result = result.replaceAllMapped( RegExp(r'\[img[^\]]*width=(\d+)[^\]]*\](.*?)\[/img\]', dotAll: true), @@ -113,130 +139,155 @@ class FormatConverter { final width = match.group(1) ?? ''; final url = match.group(2) ?? ''; return 'Image'; - } + }, ); // Tables - result = result.replaceAll('[table]', '').replaceAll('[/table]', '
    '); + result = result + .replaceAll( + '[table]', + '', + ) + .replaceAll('[/table]', '
    '); result = result.replaceAll('[tr]', '').replaceAll('[/tr]', ''); - result = result.replaceAll('[td]', '').replaceAll('[/td]', ''); - + result = result + .replaceAll('[td]', '') + .replaceAll('[/td]', ''); + // Size result = result.replaceAllMapped( - RegExp(r'\[size=([^\]]+)\](.*?)\[/size\]', dotAll: true), + RegExp(r'\[size=([^\]]+)\](.*?)\[/size\]', dotAll: true), (match) { final size = match.group(1) ?? ''; final content = match.group(2) ?? ''; return '$content'; - } + }, ); - + // Code - result = result.replaceAll('[code]', '
    ').replaceAll('[/code]', '
    '); - + result = result + .replaceAll( + '[code]', + '
    ',
    +        )
    +        .replaceAll('[/code]', '
    '); + // Quote result = result.replaceAllMapped( - RegExp(r'\[quote\](.*?)\[/quote\]', dotAll: true), + RegExp(r'\[quote\](.*?)\[/quote\]', dotAll: true), (match) { final content = match.group(1)?.trim() ?? ''; if (content.isEmpty) return ''; return '
    $content
    '; - } + }, ); - + // Handle any remaining custom BBCode tags result = result.replaceAllMapped( RegExp(r'\[([a-zA-Z0-9_]+)(?:=[^\]]+)?\](.*?)\[/\1\]', dotAll: true), - (match) => match.group(2) ?? '' + (match) => match.group(2) ?? '', ); - + // Handle RimWorld-specific patterns // [h1] without closing tag is common result = result.replaceAllMapped( - RegExp(r'\[h1\]([^\[]+)'), - (match) => '

    ${match.group(1)?.trim()}

    ' + RegExp(r'\[h1\]([^\[]+)'), + (match) => + '

    ${match.group(1)?.trim()}

    ', ); - + return result; } - + /// Converts Markdown to HTML static String _convertMarkdownToHtml(String markdown) { String result = markdown; - + // Headers // Convert # Header to

    Header

    result = result.replaceAllMapped( - RegExp(r'^#\s+(.*?)$', multiLine: true), - (match) => '

    ${match.group(1)?.trim()}

    ' + RegExp(r'^#\s+(.*?)$', multiLine: true), + (match) => + '

    ${match.group(1)?.trim()}

    ', ); - + // Convert ## Header to

    Header

    result = result.replaceAllMapped( - RegExp(r'^##\s+(.*?)$', multiLine: true), - (match) => '

    ${match.group(1)?.trim()}

    ' + RegExp(r'^##\s+(.*?)$', multiLine: true), + (match) => + '

    ${match.group(1)?.trim()}

    ', ); - + // Convert ### Header to

    Header

    result = result.replaceAllMapped( - RegExp(r'^###\s+(.*?)$', multiLine: true), - (match) => '

    ${match.group(1)?.trim()}

    ' + RegExp(r'^###\s+(.*?)$', multiLine: true), + (match) => + '

    ${match.group(1)?.trim()}

    ', ); - + // Bold - **text** to text result = result.replaceAllMapped( - RegExp(r'\*\*(.*?)\*\*'), - (match) => '${match.group(1)}' + RegExp(r'\*\*(.*?)\*\*'), + (match) => '${match.group(1)}', ); - + // Italic - *text* or _text_ to text result = result.replaceAllMapped( - RegExp(r'\*(.*?)\*|_(.*?)_'), - (match) => '${match.group(1) ?? match.group(2)}' + RegExp(r'\*(.*?)\*|_(.*?)_'), + (match) => '${match.group(1) ?? match.group(2)}', ); - + // Inline code - `code` to code result = result.replaceAllMapped( - RegExp(r'`(.*?)`'), - (match) => '${match.group(1)}' + RegExp(r'`(.*?)`'), + (match) => + '${match.group(1)}', ); - + // Links - [text](url) to text result = result.replaceAllMapped( - RegExp(r'\[(.*?)\]\((.*?)\)'), - (match) => '${match.group(1)}' + RegExp(r'\[(.*?)\]\((.*?)\)'), + (match) => + '${match.group(1)}', ); - + // Images - ![alt](url) to alt result = result.replaceAllMapped( - RegExp(r'!\[(.*?)\]\((.*?)\)'), - (match) => '${match.group(1)}' + RegExp(r'!\[(.*?)\]\((.*?)\)'), + (match) => + '${match.group(1)}', ); - + // Lists - Convert Markdown bullet lists to HTML lists // This is a simple implementation and might not handle all cases result = result.replaceAllMapped( - RegExp(r'^(\s*)\*\s+(.*?)$', multiLine: true), - (match) => '
  • ${match.group(2)}
  • ' + RegExp(r'^(\s*)\*\s+(.*?)$', multiLine: true), + (match) => '
  • ${match.group(2)}
  • ', ); - + // Wrap adjacent list items in
      tags (simple approach) result = result.replaceAll('\n
    • ', '
    • '); - result = result.replaceAll('
    • ', '
      • '); + result = result.replaceAll( + '
      • ', + '
        • ', + ); result = result.replaceAll('
        • ', '
        '); - + // Remove duplicated
        tags - result = result.replaceAll('
        ', ''); - + result = result.replaceAll( + '
        ', + '', + ); + // Paragraphs - Convert newlines to
        , but skipping where tags already exist result = result.replaceAllMapped( - RegExp(r'(?)\n(?!<)'), - (match) => '
        ' + RegExp(r'(?)\n(?!<)'), + (match) => '
        ', ); - + return result; } - + /// Performs basic sanitization and fixes for the HTML static String _sanitizeHtml(String html) { // Remove potentially dangerous elements and attributes @@ -247,11 +298,11 @@ class FormatConverter { .replaceAll(RegExp(r'\son\w+=".*?"'), '') // Ensure newlines are converted to
        if not already handled .replaceAll(RegExp(r'(?)\n(?!<)'), '
        '); - + // Fix double paragraph or break issues return result .replaceAll('

        ', '
        ') .replaceAll('

        ', '
        ') .replaceAll('

        ', ''); } -} \ No newline at end of file +} diff --git a/lib/main.dart b/lib/main.dart index 142f0dd..b7bb84e 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -6,7 +6,6 @@ import 'package:rimworld_modman/components/html_tooltip.dart'; import 'package:rimworld_modman/mod.dart'; import 'package:rimworld_modman/mod_list.dart'; import 'package:rimworld_modman/mod_troubleshooter_widget.dart'; -import 'package:rimworld_modman/widgets/mod_card_example.dart'; // Theme extension to store app-specific constants class AppThemeExtension extends ThemeExtension { @@ -245,7 +244,6 @@ class _ModManagerHomePageState extends State { final List _pages = [ const ModManagerPage(), const TroubleshootingPage(), - const ModCardExample(), ]; @override @@ -755,7 +753,10 @@ class _ModManagerPageState extends State { child: Icon( Icons.description_outlined, color: Colors.lightBlue.shade300, - size: AppThemeExtension.of(context).iconSizeRegular, + size: + AppThemeExtension.of( + context, + ).iconSizeRegular, ), ), const SizedBox(width: 4), @@ -1006,7 +1007,10 @@ class _ModManagerPageState extends State { child: Icon( Icons.description_outlined, color: Colors.lightBlue.shade300, - size: AppThemeExtension.of(context).iconSizeRegular, + size: + AppThemeExtension.of( + context, + ).iconSizeRegular, ), ), const SizedBox(width: 4), diff --git a/lib/markdown_tooltip.dart b/lib/markdown_tooltip.dart deleted file mode 100644 index e5461cb..0000000 --- a/lib/markdown_tooltip.dart +++ /dev/null @@ -1,512 +0,0 @@ -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 { - /// The markdown text to display in the tooltip. - final String markdownContent; - - /// The widget that will trigger the tooltip. - final Widget child; - - /// Optional preferred width for the tooltip. - final double preferredWidth; - - /// Optional maximum height for the tooltip. - final double maxHeight; - - /// Optional text style theme for the markdown. - final MarkdownStyleSheet? styleSheet; - - /// Creates a tooltip that displays markdown content when hovered. - const MarkdownTooltip({ - super.key, - required this.markdownContent, - required this.child, - this.preferredWidth = 800.0, - this.maxHeight = 500.0, - this.styleSheet, - }); - - @override - State createState() => _MarkdownTooltipState(); -} - -class _MarkdownTooltipState extends State { - OverlayEntry? _overlayEntry; - 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(); - } - - void _showTooltip() { - if (_isTooltipVisible || !mounted) return; - - try { - _overlayEntry = _createOverlayEntry(); - final overlay = Overlay.of(context); - if (overlay.mounted) { - overlay.insert(_overlayEntry!); - _isTooltipVisible = true; - _addGlobalListener(); - } - } catch (e) { - debugPrint('Error showing tooltip: $e'); - } - } - - void _hideTooltip() { - if (_overlayEntry != null) { - try { - _overlayEntry!.remove(); - } catch (e) { - debugPrint('Error removing overlay entry: $e'); - } - _overlayEntry = null; - _isTooltipVisible = false; - } - } - - void _addGlobalListener() { - if (!_isPointerListenerActive && mounted) { - _isPointerListenerActive = true; - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - try { - GestureBinding.instance.pointerRouter.addGlobalRoute(_handleGlobalPointer); - } catch (e) { - debugPrint('Error adding global route: $e'); - } - } - }); - } - } - - void _removeGlobalListener() { - if (_isPointerListenerActive) { - try { - GestureBinding.instance.pointerRouter.removeGlobalRoute(_handleGlobalPointer); - } catch (e) { - debugPrint('Error removing global route: $e'); - } - _isPointerListenerActive = false; - } - } - - void _handleGlobalPointer(PointerEvent event) { - if (!mounted || _overlayEntry == null) { - _removeGlobalListener(); - return; - } - - if (event is PointerDownEvent) { - // Handle pointer down outside the tooltip - final RenderBox? box = context.findRenderObject() as RenderBox?; - if (box == null) return; - - final Offset position = box.localToGlobal(Offset.zero); - final Size size = box.size; - - // Check if tap is inside the trigger widget - final bool insideTrigger = event.position.dx >= position.dx && - event.position.dx <= position.dx + size.width && - event.position.dy >= position.dy && - event.position.dy <= position.dy + size.height; - - if (!insideTrigger) { - // Tap outside, hide tooltip - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - _hideTooltip(); - _removeGlobalListener(); - } - }); - } - } - } - - OverlayEntry _createOverlayEntry() { - // Calculate render box positions - final RenderBox renderBox = context.findRenderObject() as RenderBox; - final Size size = renderBox.size; - final Offset offset = renderBox.localToGlobal(Offset.zero); - - // Get screen size - final Size screenSize = MediaQuery.of(context).size; - - // Calculate tooltip position, ensuring it stays on screen - double leftPosition = offset.dx - widget.preferredWidth * 0.5 + size.width * 0.5; - double topPosition = offset.dy + size.height + 5.0; // 5px below the widget - - // Adjust horizontal position if too close to screen edges - if (leftPosition < 10) { - leftPosition = 10; // 10px padding from left edge - } else if (leftPosition + widget.preferredWidth > screenSize.width - 10) { - 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 + dynamicHeight > screenSize.height - 10; - if (showAbove) { - 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, -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 - final ThemeData theme = Theme.of(context); - final MarkdownStyleSheet defaultStyleSheet = MarkdownStyleSheet.fromTheme(theme).copyWith( - p: const TextStyle( - color: Colors.white, - fontSize: 14.0, - ), - h1: const TextStyle( - color: Colors.white, - fontSize: 18.0, - fontWeight: FontWeight.bold, - ), - h2: const TextStyle( - color: Colors.white, - fontSize: 16.0, - fontWeight: FontWeight.bold, - ), - h3: const TextStyle( - color: Colors.white, - fontSize: 15.0, - fontWeight: FontWeight.bold, - ), - blockquote: const TextStyle( - color: Color(0xFFBBBBBB), - fontSize: 14.0, - fontStyle: FontStyle.italic, - ), - code: const TextStyle( - color: Color(0xFFE0E0E0), - fontSize: 13.0, - fontFamily: 'monospace', - backgroundColor: Color(0xFF505050), - ), - codeblockDecoration: BoxDecoration( - color: const Color(0xFF404040), - borderRadius: BorderRadius.circular(4.0), - ), - a: const TextStyle( - color: Color(0xFF8AB4F8), - decoration: TextDecoration.underline, - ), - listBullet: const TextStyle( - color: Colors.white, - fontSize: 14.0, - ), - strong: const TextStyle( - color: Colors.white, - fontWeight: FontWeight.bold, - ), - em: const TextStyle( - color: Colors.white, - fontStyle: FontStyle.italic, - ), - horizontalRuleDecoration: const BoxDecoration( - border: Border( - top: BorderSide( - width: 1.0, - color: Color(0xFF606060), - ), - ), - ), - ); - - final effectiveStyleSheet = widget.styleSheet ?? defaultStyleSheet; - - return OverlayEntry( - builder: (context) { - return Positioned( - left: leftPosition, - top: topPosition, - width: widget.preferredWidth, - 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), - ), - ], - ), - 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, - ), - ), - ); - }, - ), - ), - ], - ), - ), - ], - ), - ), - ), - ), - ), - ), - ), - ); - }, - ); - } - - @override - Widget build(BuildContext context) { - return CompositedTransformTarget( - link: _layerLink, - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () { - // Toggle tooltip visibility on tap (for mobile users) - if (_isTooltipVisible) { - _hideTooltip(); - _removeGlobalListener(); - } else { - _showTooltip(); - } - }, - child: MouseRegion( - onEnter: (_) { - setState(() { - _isMouseInside = true; - }); - // Delay showing tooltip slightly to avoid accidental triggers - Future.delayed(const Duration(milliseconds: 100), () { - if (mounted && (_isMouseInside || _isMouseInsideTooltip) && !_isTooltipVisible) { - _showTooltip(); - } - }); - }, - onExit: (_) { - setState(() { - _isMouseInside = false; - }); - // Delay hiding tooltip slightly to allow moving to the tooltip itself - Future.delayed(const Duration(milliseconds: 100), () { - if (mounted && !_isMouseInside && !_isMouseInsideTooltip && _isTooltipVisible) { - _hideTooltip(); - _removeGlobalListener(); - } - }); - }, - child: widget.child, - ), - ), - ); - } -} \ No newline at end of file diff --git a/lib/mod.dart b/lib/mod.dart index 269ab7d..647b549 100644 --- a/lib/mod.dart +++ b/lib/mod.dart @@ -361,4 +361,4 @@ class Mod { enabled: enabled ?? this.enabled, ); } -} \ No newline at end of file +} diff --git a/lib/widgets/mod_card_example.dart b/lib/widgets/mod_card_example.dart deleted file mode 100644 index dd5848d..0000000 --- a/lib/widgets/mod_card_example.dart +++ /dev/null @@ -1,95 +0,0 @@ -import 'package:flutter/material.dart'; -import '../components/html_tooltip.dart'; - -class ModCardExample extends StatelessWidget { - const ModCardExample({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: const Text('Mod Description Examples'), - ), - body: Padding( - padding: const EdgeInsets.all(16.0), - child: ListView( - children: [ - _buildExampleCard( - 'HTML Description Example', - '

        My Awesome Mod

        This is a HTML description example with formatting.

        Features:

        • Feature 1
        • Feature 2
        • Feature 3

        Visit our website: example.com

        ', - 'HTML formatted mod description', - Colors.blue.shade100, - ), - const SizedBox(height: 16), - _buildExampleCard( - 'Markdown Description Example', - '# My Cool Mod\n\nThis is a **Markdown** description with _italic_ text.\n\nFeatures:\n* Feature A\n* Feature B\n* Feature C\n\nCheck out our [website](https://example.com)', - 'Markdown formatted mod description', - Colors.green.shade100, - ), - const SizedBox(height: 16), - _buildExampleCard( - 'BBCode Description Example', - '[h1]Amazing Mod[/h1]\n[b]This[/b] is a [i]BBCode[/i] description example.\n\n[b]Features:[/b]\n[list]\n[*]Feature X\n[*]Feature Y\n[*]Feature Z\n[/list]\n\nCheck out our site: [url=https://example.com]example.com[/url]', - 'BBCode formatted mod description', - Colors.orange.shade100, - ), - const SizedBox(height: 16), - _buildExampleCard( - 'Mixed Format Example', - '[h1]Mixed Format Mod[/h1]\n\n# Markdown Header\n\nThis description contains HTML, **Markdown**, and [i]BBCode[/i] all mixed together.\n\n
        • HTML List Item
        \n* Markdown List Item\n[list][*]BBCode List Item[/list]\n\nHTML Link\n[Website](https://example.com)\n[url=https://example.com]BBCode Link[/url]', - 'Description with mixed formatting', - Colors.purple.shade100, - ), - const SizedBox(height: 16), - _buildExampleCard( - 'RimWorld-Style Example', - '[h1]RimWorld Mod[/h1]\nThis mod adds several new features to enhance your RimWorld experience.\n\n[h1]Features[/h1]\n[list]\n[*]New buildings and furniture\n[*]Additional research projects\n[*]Balanced gameplay adjustments\n[/list]\n\n[h1]Compatibility[/h1]\nThis mod is compatible with:\n[list]\n[*]Core game version 1.4\n[*]Most other popular mods\n[/list]\n\n[h1]Installation[/h1]\n1. Subscribe to the mod\n2. Enable in mod menu\n3. Start a new game or load existing\n\n[h1]Credits[/h1]\nModder: YourName\nContributors: OtherPeople\n\n[h1]Links[/h1]\n[url=https://example.com/support]Support[/url] | [url=https://example.com/donate]Donate[/url]', - 'RimWorld-style mod description', - Colors.red.shade100, - ), - ], - ), - ), - ); - } - - Widget _buildExampleCard(String title, String description, String tooltip, Color color) { - return Card( - color: color, - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Text( - title, - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(width: 8), - HtmlTooltip( - content: description, - maxWidth: 400, - maxHeight: 500, - child: const Icon( - Icons.info_outline, - size: 20, - ), - ), - ], - ), - const SizedBox(height: 8), - Text(tooltip), - const SizedBox(height: 16), - const Text('Hover over the info icon to see the formatted description') - ], - ), - ), - ); - } -} \ No newline at end of file