From 399b44bc74c968315171872a5f464019d092f13d Mon Sep 17 00:00:00 2001 From: cogwheel0 <172976095+cogwheel0@users.noreply.github.com> Date: Fri, 3 Oct 2025 16:29:21 +0530 Subject: [PATCH] refactor: integrate Mermaid diagram support in markdown rendering - Added functionality to render Mermaid diagrams within markdown content, enhancing visual representation of diagrams. - Implemented a new method to build Mermaid blocks, handling both supported and unsupported platforms gracefully. - Updated the streaming markdown widget to parse and display Mermaid code blocks, ensuring a cohesive user experience. - Improved overall maintainability of the markdown configuration by centralizing Mermaid-related logic and enhancing code clarity. --- .../widgets/markdown/markdown_config.dart | 260 ++++++++++++++++++ .../markdown/streaming_markdown_widget.dart | 46 +++- 2 files changed, 303 insertions(+), 3 deletions(-) diff --git a/lib/shared/widgets/markdown/markdown_config.dart b/lib/shared/widgets/markdown/markdown_config.dart index 6d335cd..19b9964 100644 --- a/lib/shared/widgets/markdown/markdown_config.dart +++ b/lib/shared/widgets/markdown/markdown_config.dart @@ -1,10 +1,16 @@ +import 'dart:async'; import 'dart:convert'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/services.dart'; +import 'package:webview_flutter/webview_flutter.dart'; import '../../theme/theme_extensions.dart'; +import '../../theme/color_tokens.dart'; class ConduitMarkdownTheme { const ConduitMarkdownTheme({ @@ -113,6 +119,26 @@ class ConduitMarkdownConfig { ); } + static Widget buildMermaidBlock(BuildContext context, String code) { + final conduitTheme = context.conduitTheme; + final materialTheme = Theme.of(context); + + if (MermaidDiagram.isSupported) { + return _buildMermaidContainer( + context: context, + conduitTheme: conduitTheme, + materialTheme: materialTheme, + code: code, + ); + } + + return _buildUnsupportedMermaidContainer( + context: context, + conduitTheme: conduitTheme, + code: code, + ); + } + static Widget _buildImage(BuildContext context, Uri uri) { final theme = context.conduitTheme; if (uri.scheme == 'data') { @@ -203,3 +229,237 @@ class ConduitMarkdownConfig { ); } } + +Widget _buildMermaidContainer({ + required BuildContext context, + required ConduitThemeExtension conduitTheme, + required ThemeData materialTheme, + required String code, +}) { + final tokens = context.colorTokens; + return Container( + margin: const EdgeInsets.symmetric(vertical: Spacing.sm), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(AppBorderRadius.sm), + border: Border.all( + color: conduitTheme.cardBorder.withValues(alpha: 0.4), + width: BorderWidth.micro, + ), + ), + height: 360, + width: double.infinity, + child: ClipRRect( + borderRadius: BorderRadius.circular(AppBorderRadius.sm), + child: MermaidDiagram( + code: code, + brightness: materialTheme.brightness, + colorScheme: materialTheme.colorScheme, + tokens: tokens, + ), + ), + ); +} + +Widget _buildUnsupportedMermaidContainer({ + required BuildContext context, + required ConduitThemeExtension conduitTheme, + required String code, +}) { + final textStyle = AppTypography.bodySmallStyle.copyWith( + color: conduitTheme.codeText.withValues(alpha: 0.7), + ); + + return Container( + margin: const EdgeInsets.symmetric(vertical: Spacing.sm), + padding: const EdgeInsets.all(Spacing.sm), + decoration: BoxDecoration( + color: conduitTheme.surfaceContainer.withValues(alpha: 0.35), + borderRadius: BorderRadius.circular(AppBorderRadius.sm), + border: Border.all( + color: conduitTheme.cardBorder.withValues(alpha: 0.4), + width: BorderWidth.micro, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Mermaid preview is not available on this platform.', + style: textStyle, + ), + const SizedBox(height: Spacing.xs), + SelectableText( + code, + maxLines: null, + textAlign: TextAlign.left, + textDirection: TextDirection.ltr, + textWidthBasis: TextWidthBasis.parent, + style: AppTypography.codeStyle.copyWith(color: conduitTheme.codeText), + ), + ], + ), + ); +} + +class MermaidDiagram extends StatefulWidget { + const MermaidDiagram({ + super.key, + required this.code, + required this.brightness, + required this.colorScheme, + required this.tokens, + }); + + final String code; + final Brightness brightness; + final ColorScheme colorScheme; + final AppColorTokens tokens; + + static bool get isSupported => !kIsWeb; + + static Future _loadScript() { + return _scriptFuture ??= rootBundle.loadString('assets/mermaid.min.js'); + } + + static Future? _scriptFuture; + + @override + State createState() => _MermaidDiagramState(); +} + +class _MermaidDiagramState extends State { + WebViewController? _controller; + String? _script; + final Set> _gestureRecognizers = + >{ + Factory(() => EagerGestureRecognizer()), + }; + + @override + void initState() { + super.initState(); + if (!MermaidDiagram.isSupported) { + return; + } + MermaidDiagram._loadScript().then((value) { + if (!mounted) { + return; + } + _script = value; + _controller = WebViewController() + ..setJavaScriptMode(JavaScriptMode.unrestricted) + ..setBackgroundColor(Colors.transparent); + _loadHtml(); + setState(() {}); + }); + } + + @override + void didUpdateWidget(MermaidDiagram oldWidget) { + super.didUpdateWidget(oldWidget); + if (_controller == null || _script == null) { + return; + } + final codeChanged = oldWidget.code != widget.code; + final themeChanged = + oldWidget.brightness != widget.brightness || + oldWidget.colorScheme != widget.colorScheme || + oldWidget.tokens != widget.tokens; + if (codeChanged || themeChanged) { + _loadHtml(); + } + } + + @override + Widget build(BuildContext context) { + if (_controller == null) { + return const Center(child: CircularProgressIndicator()); + } + return SizedBox.expand( + child: WebViewWidget( + controller: _controller!, + gestureRecognizers: _gestureRecognizers, + ), + ); + } + + void _loadHtml() { + if (_controller == null || _script == null) { + return; + } + _controller!.loadHtmlString(_buildHtml(widget.code, _script!)); + } + + String _buildHtml(String code, String script) { + final theme = widget.brightness == Brightness.dark ? 'dark' : 'default'; + final encoded = jsonEncode(code); + final primary = _toHex(widget.tokens.brandTone60); + final secondary = _toHex(widget.tokens.accentTeal60); + final background = _toHex(widget.tokens.codeBackground); + final onBackground = _toHex(widget.tokens.codeText); + final lineColor = _toHex(widget.tokens.codeAccent); + final errorColor = _toHex(widget.tokens.statusError60); + + return ''' + + + + + + + + + +
+ + + +'''; + } + + String _toHex(Color color) { + final value = color.toARGB32(); + return '#' + '${((value >> 16) & 0xFF).toRadixString(16).padLeft(2, '0')}' + '${((value >> 8) & 0xFF).toRadixString(16).padLeft(2, '0')}' + '${(value & 0xFF).toRadixString(16).padLeft(2, '0')}' + .toUpperCase(); + } +} diff --git a/lib/shared/widgets/markdown/streaming_markdown_widget.dart b/lib/shared/widgets/markdown/streaming_markdown_widget.dart index 5546c1b..106ba76 100644 --- a/lib/shared/widgets/markdown/streaming_markdown_widget.dart +++ b/lib/shared/widgets/markdown/streaming_markdown_widget.dart @@ -27,8 +27,11 @@ class StreamingMarkdownWidget extends StatelessWidget { final normalized = ConduitMarkdownPreprocessor.normalize(content); final markdownTheme = ConduitMarkdownConfig.resolve(context); - final markdownBody = MarkdownBody( - data: normalized, + final mermaidRegex = RegExp(r'```mermaid\s*([\s\S]*?)```', multiLine: true); + final matches = mermaidRegex.allMatches(normalized).toList(); + + Widget buildMarkdown(String data) => MarkdownBody( + data: data, styleSheet: markdownTheme.styleSheet, selectable: false, imageBuilder: markdownTheme.imageBuilder, @@ -42,6 +45,40 @@ class StreamingMarkdownWidget extends StatelessWidget { }, ); + if (matches.isEmpty) { + return SelectionArea( + child: Theme( + data: Theme.of(context).copyWith( + textSelectionTheme: TextSelectionThemeData( + cursorColor: context.conduitTheme.buttonPrimary, + ), + ), + child: buildMarkdown(normalized), + ), + ); + } + + final children = []; + var currentIndex = 0; + for (final match in matches) { + final before = normalized.substring(currentIndex, match.start); + if (before.trim().isNotEmpty) { + children.add(buildMarkdown(before)); + } + + final code = match.group(1)?.trim() ?? ''; + if (code.isNotEmpty) { + children.add(ConduitMarkdownConfig.buildMermaidBlock(context, code)); + } + + currentIndex = match.end; + } + + final tail = normalized.substring(currentIndex); + if (tail.trim().isNotEmpty) { + children.add(buildMarkdown(tail)); + } + return SelectionArea( child: Theme( data: Theme.of(context).copyWith( @@ -49,7 +86,10 @@ class StreamingMarkdownWidget extends StatelessWidget { cursorColor: context.conduitTheme.buttonPrimary, ), ), - child: markdownBody, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: children, + ), ), ); }