From ccde2e4a4602e4e1a2093d9f09a44fdbd72237ff Mon Sep 17 00:00:00 2001 From: cogwheel0 <172976095+cogwheel0@users.noreply.github.com> Date: Sun, 7 Dec 2025 22:01:18 +0530 Subject: [PATCH 1/9] refactor(markdown): Replace markdown library with gpt_markdown and update styling --- .../widgets/markdown/markdown_config.dart | 955 ++++-------------- .../markdown/streaming_markdown_widget.dart | 96 +- pubspec.lock | 16 +- pubspec.yaml | 4 +- 4 files changed, 274 insertions(+), 797 deletions(-) diff --git a/lib/shared/widgets/markdown/markdown_config.dart b/lib/shared/widgets/markdown/markdown_config.dart index 58597b6..40acd50 100644 --- a/lib/shared/widgets/markdown/markdown_config.dart +++ b/lib/shared/widgets/markdown/markdown_config.dart @@ -1,4 +1,3 @@ -import 'dart:async'; import 'dart:convert'; import 'package:cached_network_image/cached_network_image.dart'; @@ -7,9 +6,8 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; import 'package:flutter_math_fork/flutter_math.dart'; -import 'package:markdown/markdown.dart' as md; +import 'package:gpt_markdown/gpt_markdown.dart'; import 'package:webview_flutter/webview_flutter.dart'; import 'package:conduit/l10n/app_localizations.dart'; @@ -29,146 +27,233 @@ class ConduitMarkdown { required BuildContext context, required String data, MarkdownLinkTapCallback? onTapLink, - bool selectable = true, - bool shrinkWrap = false, - ScrollPhysics? physics, Widget Function(Uri uri, String? title, String? alt)? imageBuilderOverride, }) { - return MarkdownBody( - data: data, - selectable: selectable, - shrinkWrap: shrinkWrap, - styleSheet: _buildStyleSheet(context), - builders: _buildCustomBuilders(context, onTapLink), - // Allow callers to override how markdown images render (e.g., to use - // EnhancedImageAttachment in assistant views). Fallback to default. - imageBuilder: (uri, title, alt) => imageBuilderOverride != null - ? imageBuilderOverride(uri, title, alt) - : _ImageBuilder(context).buildFromUri(uri), - extensionSet: md.ExtensionSet.gitHubFlavored, - onTapLink: onTapLink != null - ? (text, href, title) => onTapLink(href ?? '', title) - : null, - syntaxHighlighter: _CodeSyntaxHighlighter(context), - inlineSyntaxes: _buildInlineSyntaxes(), - blockSyntaxes: _buildBlockSyntaxes(), - ); - } - - static Widget buildBlock({ - required BuildContext context, - required String data, - MarkdownLinkTapCallback? onTapLink, - bool selectable = true, - Widget Function(Uri uri, String? title, String? alt)? imageBuilderOverride, - }) { - return build( - context: context, - data: data, - onTapLink: onTapLink, - selectable: selectable, - shrinkWrap: true, - imageBuilderOverride: imageBuilderOverride, - ); - } - - static MarkdownStyleSheet _buildStyleSheet(BuildContext context) { final theme = context.conduitTheme; final material = Theme.of(context); - final baseBody = AppTypography.bodyMediumStyle.copyWith( + final baseTextStyle = AppTypography.bodyMediumStyle.copyWith( color: theme.textPrimary, height: 1.45, ); - final secondaryBody = AppTypography.bodySmallStyle.copyWith( - color: theme.textSecondary, - height: 1.45, - ); - final codeBackground = theme.surfaceContainer.withValues(alpha: 0.55); - final borderColor = theme.cardBorder.withValues(alpha: 0.25); - - final tableBorderColor = theme.textSecondary.withValues(alpha: 0.5); - - return MarkdownStyleSheet( - p: baseBody, + final gptThemeData = GptMarkdownThemeData( + brightness: material.brightness, h1: AppTypography.headlineLargeStyle.copyWith(color: theme.textPrimary), h2: AppTypography.headlineMediumStyle.copyWith(color: theme.textPrimary), h3: AppTypography.headlineSmallStyle.copyWith(color: theme.textPrimary), h4: AppTypography.bodyLargeStyle.copyWith(color: theme.textPrimary), - h5: baseBody.copyWith(fontWeight: FontWeight.w600), - h6: secondaryBody, - a: baseBody.copyWith( - color: material.colorScheme.primary, - decoration: TextDecoration.underline, - decorationColor: material.colorScheme.primary, + h5: baseTextStyle.copyWith(fontWeight: FontWeight.w600), + h6: AppTypography.bodySmallStyle.copyWith(color: theme.textSecondary), + linkColor: material.colorScheme.primary, + linkHoverColor: material.colorScheme.primary.withValues(alpha: 0.7), + hrLineColor: theme.dividerColor, + hrLineThickness: BorderWidth.small, + highlightColor: material.colorScheme.primary.withValues(alpha: 0.2), + ); + + return GptMarkdownTheme( + gptThemeData: gptThemeData, + child: GptMarkdown( + data, + style: baseTextStyle, + useDollarSignsForLatex: true, + onLinkTap: onTapLink, + codeBuilder: (context, language, code, closed) => _buildCodeBlock( + context: context, + code: code, + language: language, + theme: theme, + ), + latexBuilder: (context, tex, textStyle, isInline) { + final math = Math.tex(tex, textStyle: textStyle); + if (isInline) return math; + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: math, + ); + }, + imageBuilder: (context, url) { + final uri = Uri.tryParse(url); + if (uri == null) { + return _buildImageError(context, theme); + } + if (imageBuilderOverride != null) { + return imageBuilderOverride(uri, null, null); + } + return _buildImage(context, uri, theme); + }, ), - code: AppTypography.codeStyle.copyWith( - color: theme.codeText, - backgroundColor: codeBackground, - ), - codeblockDecoration: BoxDecoration( + ); + } + + static Widget _buildCodeBlock({ + required BuildContext context, + required String code, + required String language, + required ConduitThemeExtension theme, + }) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final normalizedLanguage = language.trim().isEmpty + ? 'plaintext' + : language.trim(); + + // Match GitHub/Atom theme colors for code block container + final codeBackground = isDark + ? const Color(0xFF282c34) // Atom One Dark background + : const Color(0xFFfafbfc); // GitHub light background + + return Container( + margin: const EdgeInsets.symmetric(vertical: Spacing.sm), + decoration: BoxDecoration( color: codeBackground, - borderRadius: BorderRadius.circular(AppBorderRadius.sm), - border: Border.all(color: borderColor, width: BorderWidth.micro), + borderRadius: BorderRadius.circular(6), ), - codeblockPadding: const EdgeInsets.all(Spacing.sm), - blockquoteDecoration: BoxDecoration( - border: Border( - left: BorderSide( - color: material.colorScheme.primary.withValues(alpha: 0.35), - width: BorderWidth.small, + clipBehavior: Clip.antiAlias, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + CodeBlockHeader( + language: normalizedLanguage, + onCopy: () async { + await Clipboard.setData(ClipboardData(text: code)); + if (!context.mounted) { + return; + } + ScaffoldMessenger.of(context).hideCurrentSnackBar(); + final l10n = AppLocalizations.of(context); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + l10n?.codeCopiedToClipboard ?? 'Code copied to clipboard.', + ), + ), + ); + }, + ), + SingleChildScrollView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.all(Spacing.md), + child: SelectableText( + code, + style: AppTypography.codeStyle.copyWith( + color: theme.codeText, + fontFamily: AppTypography.monospaceFontFamily, + ), + ), + ), + ], + ), + ); + } + + static Widget _buildImage( + BuildContext context, + Uri uri, + ConduitThemeExtension theme, + ) { + if (uri.scheme == 'data') { + return _buildBase64Image(uri.toString(), context, theme); + } + if (uri.scheme.isEmpty || uri.scheme == 'http' || uri.scheme == 'https') { + return _buildNetworkImage(uri.toString(), context, theme); + } + return _buildImageError(context, theme); + } + + static Widget _buildBase64Image( + String dataUrl, + BuildContext context, + ConduitThemeExtension theme, + ) { + try { + final commaIndex = dataUrl.indexOf(','); + if (commaIndex == -1) { + throw FormatException( + AppLocalizations.of(context)?.invalidDataUrl ?? + 'Invalid data URL format', + ); + } + + final base64String = dataUrl.substring(commaIndex + 1); + final imageBytes = base64.decode(base64String); + + return Container( + margin: const EdgeInsets.symmetric(vertical: Spacing.sm), + constraints: const BoxConstraints(maxWidth: 480, maxHeight: 480), + child: ClipRRect( + borderRadius: BorderRadius.circular(AppBorderRadius.md), + child: Image.memory( + imageBytes, + fit: BoxFit.contain, + errorBuilder: (context, error, stackTrace) { + return _buildImageError(context, theme); + }, + ), + ), + ); + } catch (_) { + return _buildImageError(context, theme); + } + } + + static Widget _buildNetworkImage( + String url, + BuildContext context, + ConduitThemeExtension theme, + ) { + // Read headers and optional self-signed cache manager from Riverpod + final container = ProviderScope.containerOf(context, listen: false); + final headers = buildImageHeadersFromContainer(container); + final cacheManager = container.read(selfSignedImageCacheManagerProvider); + + return CachedNetworkImage( + imageUrl: url, + cacheManager: cacheManager, + httpHeaders: headers, + placeholder: (context, _) => Container( + height: 200, + decoration: BoxDecoration( + color: theme.surfaceBackground.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(AppBorderRadius.md), + ), + child: Center( + child: CircularProgressIndicator( + color: theme.loadingIndicator, + strokeWidth: 2, ), ), ), - blockquotePadding: const EdgeInsets.symmetric( - horizontal: Spacing.md, - vertical: Spacing.sm, - ), - blockquote: secondaryBody, - listBullet: baseBody, - listIndent: Spacing.lg, - tableHead: secondaryBody.copyWith(fontWeight: FontWeight.w600), - tableBody: secondaryBody, - tableBorder: TableBorder.all( - color: tableBorderColor, - width: BorderWidth.thin, - ), - tableHeadAlign: TextAlign.start, - // Use IntrinsicColumnWidth so columns size to content instead of being - // squashed. Tables are wrapped in horizontal scroll for overflow. - tableColumnWidth: const IntrinsicColumnWidth(), - tableCellsPadding: const EdgeInsets.symmetric( - horizontal: Spacing.md, - vertical: Spacing.sm, - ), - horizontalRuleDecoration: BoxDecoration( - border: Border( - top: BorderSide(color: theme.dividerColor, width: BorderWidth.small), + errorWidget: (context, url, error) => _buildImageError(context, theme), + imageBuilder: (context, imageProvider) => Container( + margin: const EdgeInsets.symmetric(vertical: Spacing.sm), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(AppBorderRadius.md), + image: DecorationImage(image: imageProvider, fit: BoxFit.contain), ), ), ); } - static Map _buildCustomBuilders( + static Widget _buildImageError( BuildContext context, - MarkdownLinkTapCallback? onTapLink, + ConduitThemeExtension theme, ) { - return { - 'code': _CodeBlockBuilder(context), - 'mermaid': _MermaidBuilder(context), - 'latex': _LatexBuilder(context), - 'details': _DetailsBuilder(context), - 'table': _TableBuilder(context), - }; - } - - static List _buildInlineSyntaxes() { - return [_LatexInlineSyntax()]; - } - - static List _buildBlockSyntaxes() { - return [_DetailsBlockSyntax()]; + return Container( + height: 120, + decoration: BoxDecoration( + color: theme.surfaceBackground.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(AppBorderRadius.md), + border: Border.all( + color: theme.cardBorder.withValues(alpha: 0.4), + width: BorderWidth.micro, + ), + ), + child: Center( + child: Icon(Icons.broken_image_outlined, color: theme.iconSecondary), + ), + ); } static Widget buildMermaidBlock(BuildContext context, String code) { @@ -272,6 +357,17 @@ class ConduitMarkdown { return html.contains('new Chart(') || html.contains('Chart.'); } + /// Converts a Color to a hex string for use in HTML/CSS. + static String colorToHex(Color color) { + int channel(double value) => (value * 255).round().clamp(0, 255); + final rgba = + (channel(color.r) << 24) | + (channel(color.g) << 16) | + (channel(color.b) << 8) | + channel(color.a); + return '#${rgba.toRadixString(16).padLeft(8, '0')}'; + } + /// Builds a ChartJS block for rendering in a WebView. static Widget buildChartJsBlock(BuildContext context, String htmlContent) { final conduitTheme = context.conduitTheme; @@ -351,476 +447,6 @@ class ConduitMarkdown { } } -// Code syntax highlighting -class _CodeSyntaxHighlighter extends SyntaxHighlighter { - _CodeSyntaxHighlighter(this.context); - - final BuildContext context; - - @override - TextSpan format(String source) { - final theme = context.conduitTheme; - - return TextSpan( - style: AppTypography.codeStyle.copyWith(color: theme.codeText), - children: [TextSpan(text: source)], - ); - } -} - -// Custom code block builder with header -class _CodeBlockBuilder extends MarkdownElementBuilder { - _CodeBlockBuilder(this.context); - - final BuildContext context; - - @override - Widget? visitElementAfter(md.Element element, TextStyle? preferredStyle) { - final theme = context.conduitTheme; - final isDark = Theme.of(context).brightness == Brightness.dark; - final code = element.textContent; - final language = - element.attributes['class']?.replaceFirst('language-', '') ?? - 'plaintext'; - final normalizedLanguage = language.trim().isEmpty - ? 'plaintext' - : language.trim(); - - // Match GitHub/Atom theme colors for code block container - final codeBackground = isDark - ? const Color(0xFF282c34) // Atom One Dark background - : const Color(0xFFfafbfc); // GitHub light background - - return Container( - margin: const EdgeInsets.symmetric(vertical: Spacing.sm), - decoration: BoxDecoration( - color: codeBackground, - borderRadius: BorderRadius.circular(6), - ), - clipBehavior: Clip.antiAlias, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - CodeBlockHeader( - language: normalizedLanguage, - onCopy: () async { - await Clipboard.setData(ClipboardData(text: code)); - if (!context.mounted) { - return; - } - ScaffoldMessenger.of(context).hideCurrentSnackBar(); - final l10n = AppLocalizations.of(context); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - l10n?.codeCopiedToClipboard ?? 'Code copied to clipboard.', - ), - ), - ); - }, - ), - SingleChildScrollView( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.all(Spacing.md), - child: SelectableText( - code, - style: AppTypography.codeStyle.copyWith( - color: theme.codeText, - fontFamily: AppTypography.monospaceFontFamily, - ), - ), - ), - ], - ), - ); - } -} - -// Custom table builder for horizontally scrollable tables -class _TableBuilder extends MarkdownElementBuilder { - _TableBuilder(this.context); - - final BuildContext context; - - @override - Widget? visitElementAfter(md.Element element, TextStyle? preferredStyle) { - final theme = context.conduitTheme; - final tableBorderColor = theme.textSecondary.withValues(alpha: 0.5); - final headerBgColor = theme.surfaceContainer.withValues(alpha: 0.4); - - // Collect row data first to determine max column count - final rowData = <_TableRowData>[]; - - // Parse table structure - for (final child in element.children ?? []) { - if (child is! md.Element) continue; - - final isHeader = child.tag == 'thead'; - final bodyElement = child.tag == 'tbody' ? child : null; - - // Handle thead - if (isHeader) { - for (final row in child.children ?? []) { - if (row is! md.Element || row.tag != 'tr') continue; - rowData.add(_parseTableRow(row, isHeader: true)); - } - } - - // Handle tbody - if (bodyElement != null) { - for (final row in bodyElement.children ?? []) { - if (row is! md.Element || row.tag != 'tr') continue; - rowData.add(_parseTableRow(row, isHeader: false)); - } - } - - // Handle direct tr children (some markdown parsers) - if (child.tag == 'tr') { - final hasHeaderCells = (child.children ?? []).any( - (c) => c is md.Element && c.tag == 'th', - ); - rowData.add(_parseTableRow(child, isHeader: hasHeaderCells)); - } - } - - if (rowData.isEmpty) return null; - - // Find max column count to ensure all rows have same cell count - final maxColumns = rowData.fold( - 0, - (max, row) => row.cells.length > max ? row.cells.length : max, - ); - - if (maxColumns == 0) return null; - - // Build TableRows, padding shorter rows with empty cells - final rows = rowData.map((data) { - return _buildTableRow( - data, - maxColumns: maxColumns, - headerBgColor: headerBgColor, - ); - }).toList(); - - // Use symmetric borders for internal cell dividers only; - // the Container provides the outer border with rounded corners - final cellBorder = BorderSide( - color: tableBorderColor, - width: BorderWidth.thin, - ); - final table = Table( - border: TableBorder.symmetric(inside: cellBorder), - defaultColumnWidth: const IntrinsicColumnWidth(), - defaultVerticalAlignment: TableCellVerticalAlignment.middle, - children: rows, - ); - - // Wrap in horizontal scroll for tables that overflow - return Container( - margin: const EdgeInsets.symmetric(vertical: Spacing.sm), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(AppBorderRadius.xs), - border: Border.all(color: tableBorderColor, width: BorderWidth.thin), - ), - clipBehavior: Clip.antiAlias, - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: table, - ), - ); - } - - /// Parses a table row element into cell data without building widgets yet. - _TableRowData _parseTableRow(md.Element row, {required bool isHeader}) { - final cells = []; - for (final cell in row.children ?? []) { - if (cell is! md.Element) continue; - if (cell.tag != 'th' && cell.tag != 'td') continue; - cells.add(_extractText(cell)); - } - return _TableRowData(cells: cells, isHeader: isHeader); - } - - /// Builds a TableRow from parsed data, padding with empty cells if needed. - TableRow _buildTableRow( - _TableRowData data, { - required int maxColumns, - Color? headerBgColor, - }) { - final theme = context.conduitTheme; - final cells = []; - - final textStyle = data.isHeader - ? AppTypography.bodySmallStyle.copyWith( - color: theme.textSecondary, - fontWeight: FontWeight.w600, - ) - : AppTypography.bodySmallStyle.copyWith(color: theme.textSecondary); - - // Build cells from parsed data - for (final cellText in data.cells) { - cells.add( - Container( - color: data.isHeader ? headerBgColor : null, - padding: const EdgeInsets.symmetric( - horizontal: Spacing.md, - vertical: Spacing.sm, - ), - child: Text(cellText, style: textStyle, softWrap: false), - ), - ); - } - - // Pad with empty cells if this row has fewer columns than max - while (cells.length < maxColumns) { - cells.add( - Container( - color: data.isHeader ? headerBgColor : null, - padding: const EdgeInsets.symmetric( - horizontal: Spacing.md, - vertical: Spacing.sm, - ), - child: Text('', style: textStyle), - ), - ); - } - - return TableRow(children: cells); - } - - String _extractText(md.Element element) { - final buffer = StringBuffer(); - for (final node in element.children ?? []) { - if (node is md.Text) { - buffer.write(node.text); - } else if (node is md.Element) { - buffer.write(_extractText(node)); - } - } - return buffer.toString(); - } -} - -/// Intermediate data structure for table row parsing. -class _TableRowData { - const _TableRowData({required this.cells, required this.isHeader}); - - final List cells; - final bool isHeader; -} - -// Custom image builder -class _ImageBuilder extends MarkdownElementBuilder { - _ImageBuilder(this.context); - - final BuildContext context; - - @override - Widget? visitElementAfter(md.Element element, TextStyle? preferredStyle) { - final url = element.attributes['src'] ?? ''; - final uri = Uri.tryParse(url); - if (uri == null) { - return _buildImageError(context, context.conduitTheme); - } - return buildFromUri(uri); - } - - /// Public helper used by the Markdown `imageBuilder` callback. - Widget buildFromUri(Uri uri) { - final theme = context.conduitTheme; - if (uri.scheme == 'data') { - return _buildBase64Image(uri.toString(), context, theme); - } - if (uri.scheme.isEmpty || uri.scheme == 'http' || uri.scheme == 'https') { - return _buildNetworkImage(uri.toString(), context, theme); - } - return _buildImageError(context, theme); - } - - Widget _buildBase64Image( - String dataUrl, - BuildContext context, - ConduitThemeExtension theme, - ) { - try { - final commaIndex = dataUrl.indexOf(','); - if (commaIndex == -1) { - throw FormatException( - AppLocalizations.of(context)?.invalidDataUrl ?? - 'Invalid data URL format', - ); - } - - final base64String = dataUrl.substring(commaIndex + 1); - final imageBytes = base64.decode(base64String); - - return Container( - margin: const EdgeInsets.symmetric(vertical: Spacing.sm), - constraints: const BoxConstraints(maxWidth: 480, maxHeight: 480), - child: ClipRRect( - borderRadius: BorderRadius.circular(AppBorderRadius.md), - child: Image.memory( - imageBytes, - fit: BoxFit.contain, - errorBuilder: (context, error, stackTrace) { - return _buildImageError(context, theme); - }, - ), - ), - ); - } catch (_) { - return _buildImageError(context, theme); - } - } - - Widget _buildNetworkImage( - String url, - BuildContext context, - ConduitThemeExtension theme, - ) { - // Read headers and optional self-signed cache manager from Riverpod - final container = ProviderScope.containerOf(context, listen: false); - final headers = buildImageHeadersFromContainer(container); - final cacheManager = container.read(selfSignedImageCacheManagerProvider); - - return CachedNetworkImage( - imageUrl: url, - cacheManager: cacheManager, - httpHeaders: headers, - placeholder: (context, _) => Container( - height: 200, - decoration: BoxDecoration( - color: theme.surfaceBackground.withValues(alpha: 0.5), - borderRadius: BorderRadius.circular(AppBorderRadius.md), - ), - child: Center( - child: CircularProgressIndicator( - color: theme.loadingIndicator, - strokeWidth: 2, - ), - ), - ), - errorWidget: (context, url, error) => _buildImageError(context, theme), - imageBuilder: (context, imageProvider) => Container( - margin: const EdgeInsets.symmetric(vertical: Spacing.sm), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(AppBorderRadius.md), - image: DecorationImage(image: imageProvider, fit: BoxFit.contain), - ), - ), - ); - } - - Widget _buildImageError(BuildContext context, ConduitThemeExtension theme) { - return Container( - height: 120, - decoration: BoxDecoration( - color: theme.surfaceBackground.withValues(alpha: 0.3), - borderRadius: BorderRadius.circular(AppBorderRadius.md), - border: Border.all( - color: theme.cardBorder.withValues(alpha: 0.4), - width: BorderWidth.micro, - ), - ), - child: Center( - child: Icon(Icons.broken_image_outlined, color: theme.iconSecondary), - ), - ); - } -} - -// Mermaid diagram builder -class _MermaidBuilder extends MarkdownElementBuilder { - _MermaidBuilder(this.context); - - final BuildContext context; - - @override - Widget? visitElementAfter(md.Element element, TextStyle? preferredStyle) { - final code = element.textContent; - return ConduitMarkdown.buildMermaidBlock(context, code); - } -} - -// LaTeX builder -class _LatexBuilder extends MarkdownElementBuilder { - _LatexBuilder(this.context); - - final BuildContext context; - - @override - Widget? visitElementAfter(md.Element element, TextStyle? preferredStyle) { - final isDark = Theme.of(context).brightness == Brightness.dark; - final content = element.textContent.trim(); - final isInline = element.attributes['isInline'] == 'true'; - - final baseStyle = (preferredStyle ?? AppTypography.bodyMediumStyle) - .copyWith(color: isDark ? Colors.white : Colors.black); - - if (content.isEmpty) { - return Text(element.textContent, style: baseStyle); - } - - final mathWidget = Math.tex( - content, - mathStyle: MathStyle.text, - textStyle: baseStyle, - textScaleFactor: 1, - onErrorFallback: (error) { - return Text(content, style: baseStyle.copyWith(color: Colors.red)); - }, - ); - - if (isInline) { - return mathWidget; - } - - // Wrap block math in horizontal scroll for long expressions - return Padding( - padding: const EdgeInsets.symmetric(vertical: Spacing.xs), - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: mathWidget, - ), - ); - } -} - -// LaTeX inline syntax -class _LatexInlineSyntax extends md.InlineSyntax { - _LatexInlineSyntax() - : super( - r'(\$\$[\s\S]+?\$\$)|(\$[^\n]+?\$)|(\\\([\s\S]+?\\\))|(\\\[[\s\S]+?\\\])', - ); - - @override - bool onMatch(md.InlineParser parser, Match match) { - final raw = match.group(0) ?? ''; - String content = raw; - bool isInline = true; - - if (raw.startsWith(r'$$') && raw.endsWith(r'$$') && raw.length > 4) { - content = raw.substring(2, raw.length - 2); - isInline = false; - } else if (raw.startsWith(r'$') && raw.endsWith(r'$') && raw.length > 2) { - content = raw.substring(1, raw.length - 1); - isInline = true; - } else if (raw.startsWith(r'\(') && raw.endsWith(r'\)') && raw.length > 4) { - content = raw.substring(2, raw.length - 2); - isInline = true; - } else if (raw.startsWith(r'\[') && raw.endsWith(r'\]') && raw.length > 4) { - content = raw.substring(2, raw.length - 2); - isInline = false; - } - - final element = md.Element.text('latex', content); - element.attributes['isInline'] = isInline.toString(); - parser.addNode(element); - return true; - } -} - // ChartJS diagram WebView widget class ChartJsDiagram extends StatefulWidget { const ChartJsDiagram({ @@ -913,18 +539,16 @@ class _ChartJsDiagramState extends State { String _buildHtml(String htmlContent, String script) { final isDark = widget.brightness == Brightness.dark; - final background = _toHex( + final background = ConduitMarkdown.colorToHex( isDark ? widget.tokens.codeBackground : Colors.white, ); - final textColor = _toHex(widget.tokens.codeText); - final gridColor = _toHex( + final textColor = ConduitMarkdown.colorToHex(widget.tokens.codeText); + final gridColor = ConduitMarkdown.colorToHex( isDark ? Colors.white.withValues(alpha: 0.1) : Colors.black.withValues(alpha: 0.1), ); - // Process the HTML content to inject Chart.js and configure theme - // The htmlContent contains the full HTML with chart creation code return ''' @@ -965,31 +589,21 @@ class _ChartJsDiagramState extends State {