diff --git a/lib/shared/widgets/markdown/latex_block_widget.dart b/lib/shared/widgets/markdown/latex_block_widget.dart deleted file mode 100644 index d45c8d5..0000000 --- a/lib/shared/widgets/markdown/latex_block_widget.dart +++ /dev/null @@ -1,41 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_math_fork/flutter_math.dart'; - -import '../../theme/theme_extensions.dart'; - -class LatexBlockWidget extends StatelessWidget { - const LatexBlockWidget({ - super.key, - required this.content, - required this.isInline, - required this.style, - required this.isDark, - }); - - final String content; - final bool isInline; - final TextStyle style; - final bool isDark; - - @override - Widget build(BuildContext context) { - final mathWidget = Math.tex( - content, - mathStyle: MathStyle.text, - textStyle: style, - textScaleFactor: 1, - onErrorFallback: (error) { - return Text(content, style: style.copyWith(color: Colors.red)); - }, - ); - - if (isInline) { - return mathWidget; - } - - return Padding( - padding: const EdgeInsets.symmetric(vertical: Spacing.xs), - child: Center(child: mathWidget), - ); - } -} diff --git a/lib/shared/widgets/markdown/markdown_config.dart b/lib/shared/widgets/markdown/markdown_config.dart index 6a3c14a..3a601ab 100644 --- a/lib/shared/widgets/markdown/markdown_config.dart +++ b/lib/shared/widgets/markdown/markdown_config.dart @@ -9,20 +9,21 @@ import 'package:flutter/services.dart'; import 'package:flutter_highlight/flutter_highlight.dart'; import 'package:flutter_highlight/themes/a11y-dark.dart'; import 'package:flutter_highlight/themes/a11y-light.dart'; -import 'package:markdown_widget/markdown_widget.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:webview_flutter/webview_flutter.dart'; import '../../theme/color_tokens.dart'; import '../../theme/theme_extensions.dart'; import 'code_block_header.dart'; -import 'markdown_latex.dart'; typedef MarkdownLinkTapCallback = void Function(String url, String title); class ConduitMarkdown { const ConduitMarkdown._(); - static MarkdownWidget build({ + static Widget build({ required BuildContext context, required String data, MarkdownLinkTapCallback? onTapLink, @@ -30,49 +31,39 @@ class ConduitMarkdown { bool shrinkWrap = false, ScrollPhysics? physics, }) { - final components = prepare(context, onTapLink: onTapLink); - return MarkdownWidget( + return MarkdownBody( data: data, selectable: selectable, - config: components.config, - markdownGenerator: components.generator, shrinkWrap: shrinkWrap, - physics: physics, - padding: EdgeInsets.zero, + styleSheet: _buildStyleSheet(context), + builders: _buildCustomBuilders(context, onTapLink), + extensionSet: md.ExtensionSet.gitHubFlavored, + onTapLink: onTapLink != null + ? (text, href, title) => onTapLink(href ?? '', title) + : null, + syntaxHighlighter: _CodeSyntaxHighlighter(context), + inlineSyntaxes: _buildInlineSyntaxes(), ); } - static MarkdownBlock buildBlock({ + static Widget buildBlock({ required BuildContext context, required String data, MarkdownLinkTapCallback? onTapLink, bool selectable = true, }) { - final components = prepare(context, onTapLink: onTapLink); - return MarkdownBlock( + return build( + context: context, data: data, + onTapLink: onTapLink, selectable: selectable, - config: components.config, - generator: components.generator, + shrinkWrap: true, ); } - static ({MarkdownConfig config, MarkdownGenerator generator}) prepare( - BuildContext context, { - MarkdownLinkTapCallback? onTapLink, - }) { - final config = _buildConfig(context, onTapLink: onTapLink); - final generator = _buildGenerator(context); - return (config: config, generator: generator); - } - - static MarkdownConfig _buildConfig( - BuildContext context, { - MarkdownLinkTapCallback? onTapLink, - }) { + static MarkdownStyleSheet _buildStyleSheet(BuildContext context) { final theme = context.conduitTheme; final material = Theme.of(context); - final isDark = material.brightness == Brightness.dark; final baseBody = AppTypography.bodyMediumStyle.copyWith( color: theme.textPrimary, @@ -85,209 +76,89 @@ class ConduitMarkdown { final codeBackground = theme.surfaceContainer.withValues(alpha: 0.55); final borderColor = theme.cardBorder.withValues(alpha: 0.25); - final highlightTheme = _codeHighlightTheme(theme, isDark: isDark); - return MarkdownConfig( - configs: [ - PConfig(textStyle: baseBody), - H1Config( - style: AppTypography.headlineLargeStyle.copyWith( - color: theme.textPrimary, + return MarkdownStyleSheet( + p: baseBody, + 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, + ), + code: AppTypography.codeStyle.copyWith( + color: theme.codeText, + backgroundColor: codeBackground, + ), + codeblockDecoration: BoxDecoration( + color: codeBackground, + borderRadius: BorderRadius.circular(AppBorderRadius.sm), + border: Border.all(color: borderColor, width: BorderWidth.micro), + ), + codeblockPadding: const EdgeInsets.all(Spacing.sm), + blockquoteDecoration: BoxDecoration( + border: Border( + left: BorderSide( + color: material.colorScheme.primary.withValues(alpha: 0.35), + width: BorderWidth.small, ), ), - H2Config( - style: AppTypography.headlineMediumStyle.copyWith( - color: theme.textPrimary, + ), + 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: borderColor, width: BorderWidth.micro), + tableHeadAlign: TextAlign.start, + tableColumnWidth: const FlexColumnWidth(), + tableCellsPadding: const EdgeInsets.symmetric( + horizontal: Spacing.sm, + vertical: Spacing.xs, + ), + horizontalRuleDecoration: BoxDecoration( + border: Border( + top: BorderSide( + color: theme.dividerColor, + width: BorderWidth.small, ), ), - H3Config( - style: AppTypography.headlineSmallStyle.copyWith( - color: theme.textPrimary, - ), - ), - H4Config( - style: AppTypography.bodyLargeStyle.copyWith( - color: theme.textPrimary, - ), - ), - H5Config(style: baseBody.copyWith(fontWeight: FontWeight.w600)), - H6Config(style: secondaryBody), - LinkConfig( - style: baseBody.copyWith( - color: material.colorScheme.primary, - decoration: TextDecoration.underline, - decorationColor: material.colorScheme.primary, - ), - onTap: (url) => onTapLink?.call(url, url), - ), - CodeConfig( - style: AppTypography.codeStyle.copyWith( - color: theme.codeText, - backgroundColor: codeBackground, - ), - ), - PreConfig( - textStyle: AppTypography.codeStyle.copyWith(color: theme.codeText), - styleNotMatched: AppTypography.codeStyle.copyWith( - color: theme.codeText, - ), - theme: highlightTheme, - builder: (code, language) { - final normalizedLanguage = language.trim().isEmpty - ? 'plaintext' - : language.trim(); - final highlight = HighlightView( - code, - language: normalizedLanguage == 'plaintext' - ? null - : normalizedLanguage, - theme: highlightTheme, - textStyle: AppTypography.codeStyle.copyWith( - color: theme.codeText, - ), - padding: EdgeInsets.zero, - ); - return _buildCodeWrapper( - context: context, - child: highlight, - backgroundColor: codeBackground, - borderColor: borderColor, - language: normalizedLanguage, - rawCode: code, - ); - }, - ), - BlockquoteConfig( - sideColor: material.colorScheme.primary.withValues(alpha: 0.35), - textColor: theme.textSecondary, - sideWith: BorderWidth.small, - padding: const EdgeInsets.symmetric( - horizontal: Spacing.md, - vertical: Spacing.sm, - ), - margin: const EdgeInsets.symmetric(vertical: Spacing.sm), - ), - ListConfig(marginLeft: Spacing.lg, marginBottom: Spacing.xs), - TableConfig( - border: TableBorder.all(color: borderColor, width: BorderWidth.micro), - headPadding: const EdgeInsets.symmetric( - horizontal: Spacing.sm, - vertical: Spacing.xs, - ), - bodyPadding: const EdgeInsets.symmetric( - horizontal: Spacing.sm, - vertical: Spacing.xs, - ), - headerStyle: secondaryBody.copyWith(fontWeight: FontWeight.w600), - bodyStyle: secondaryBody, - headerRowDecoration: BoxDecoration( - color: theme.surfaceBackground.withValues(alpha: 0.35), - ), - bodyRowDecoration: BoxDecoration( - color: theme.surfaceContainer.withValues(alpha: 0.2), - ), - ), - HrConfig(color: theme.dividerColor, height: BorderWidth.small), - ImgConfig( - builder: (url, attributes) { - final uri = Uri.tryParse(url); - if (uri == null) { - return _buildImageError(context, theme); - } - return _buildImage(context, uri); - }, - ), - ], + ), ); } - static MarkdownGenerator _buildGenerator(BuildContext context) { - final isDark = Theme.of(context).brightness == Brightness.dark; - final latex = ConduitLatex(); - return MarkdownGenerator( - inlineSyntaxList: latex.syntaxes(), - generators: [latex.generator(isDark: isDark)], - linesMargin: const EdgeInsets.symmetric(vertical: Spacing.xs), - ); - } - - static Map _codeHighlightTheme( - ConduitThemeExtension theme, { - required bool isDark, - }) { - final baseTheme = isDark ? a11yDarkTheme : a11yLightTheme; - final codeStyle = AppTypography.codeStyle.copyWith(color: theme.codeText); - + static Map _buildCustomBuilders( + BuildContext context, + MarkdownLinkTapCallback? onTapLink, + ) { return { - for (final entry in baseTheme.entries) - entry.key: entry.value.copyWith( - color: entry.value.color ?? theme.codeText, - fontFamily: AppTypography.monospaceFontFamily, - fontSize: codeStyle.fontSize, - height: codeStyle.height, - ), + 'code': _CodeBlockBuilder(context), + 'img': _ImageBuilder(context), + 'mermaid': _MermaidBuilder(context), + 'latex': _LatexBuilder(context), }; } - static Widget _buildCodeWrapper({ - required BuildContext context, - required Widget child, - required Color backgroundColor, - required Color borderColor, - required String language, - required String rawCode, - }) { - return LayoutBuilder( - builder: (context, constraints) { - final width = constraints.maxWidth.isFinite - ? constraints.maxWidth - : MediaQuery.sizeOf(context).width; - - return Container( - margin: const EdgeInsets.symmetric(vertical: Spacing.xs), - decoration: BoxDecoration( - color: backgroundColor, - borderRadius: BorderRadius.circular(AppBorderRadius.sm), - border: Border.all(color: borderColor, width: BorderWidth.micro), - ), - child: ClipRect( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - CodeBlockHeader( - language: language, - onCopy: () async { - await Clipboard.setData(ClipboardData(text: rawCode)); - if (!context.mounted) { - return; - } - ScaffoldMessenger.of(context).hideCurrentSnackBar(); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Code copied to clipboard.'), - ), - ); - }, - ), - SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: IntrinsicWidth( - child: ConstrainedBox( - constraints: BoxConstraints(minWidth: width), - child: Padding( - padding: const EdgeInsets.all(Spacing.sm), - child: child, - ), - ), - ), - ), - ], - ), - ), - ); - }, - ); + static List _buildInlineSyntaxes() { + return [ + _LatexInlineSyntax(), + ]; } static Widget buildMermaidBlock(BuildContext context, String code) { @@ -310,8 +181,211 @@ class ConduitMarkdown { ); } - static Widget _buildImage(BuildContext context, Uri uri) { + static 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, + ), + ), + ); + } + + static 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), + ), + ], + ), + ); + } +} + +// 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 highlightTheme = _getCodeHighlightTheme(theme, isDark: isDark); + final normalizedLanguage = language.trim().isEmpty ? 'plaintext' : language.trim(); + + final highlight = HighlightView( + code, + language: normalizedLanguage == 'plaintext' ? null : normalizedLanguage, + theme: highlightTheme, + textStyle: AppTypography.codeStyle.copyWith(color: theme.codeText), + padding: EdgeInsets.zero, + ); + + final codeBackground = theme.surfaceContainer.withValues(alpha: 0.55); + final borderColor = theme.cardBorder.withValues(alpha: 0.25); + + return LayoutBuilder( + builder: (context, constraints) { + final width = constraints.maxWidth.isFinite + ? constraints.maxWidth + : MediaQuery.sizeOf(context).width; + + return Container( + margin: const EdgeInsets.symmetric(vertical: Spacing.xs), + decoration: BoxDecoration( + color: codeBackground, + borderRadius: BorderRadius.circular(AppBorderRadius.sm), + border: Border.all(color: borderColor, width: BorderWidth.micro), + ), + child: ClipRect( + 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(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Code copied to clipboard.'), + ), + ); + }, + ), + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: IntrinsicWidth( + child: ConstrainedBox( + constraints: BoxConstraints(minWidth: width), + child: Padding( + padding: const EdgeInsets.all(Spacing.sm), + child: highlight, + ), + ), + ), + ), + ], + ), + ), + ); + }, + ); + } + + Map _getCodeHighlightTheme( + ConduitThemeExtension theme, { + required bool isDark, + }) { + final baseTheme = isDark ? a11yDarkTheme : a11yLightTheme; + final codeStyle = AppTypography.codeStyle.copyWith(color: theme.codeText); + + return { + for (final entry in baseTheme.entries) + entry.key: entry.value.copyWith( + color: entry.value.color ?? theme.codeText, + fontFamily: AppTypography.monospaceFontFamily, + fontSize: codeStyle.fontSize, + height: codeStyle.height, + ), + }; + } +} + +// Custom image builder +class _ImageBuilder extends MarkdownElementBuilder { + _ImageBuilder(this.context); + + final BuildContext context; + + @override + Widget? visitElementAfter(md.Element element, TextStyle? preferredStyle) { + final theme = context.conduitTheme; + final url = element.attributes['src'] ?? ''; + final uri = Uri.tryParse(url); + + if (uri == null) { + return _buildImageError(context, theme); + } + if (uri.scheme == 'data') { return _buildBase64Image(uri.toString(), context, theme); } @@ -323,7 +397,7 @@ class ConduitMarkdown { return _buildImageError(context, theme); } - static Widget _buildBase64Image( + Widget _buildBase64Image( String dataUrl, BuildContext context, ConduitThemeExtension theme, @@ -356,7 +430,7 @@ class ConduitMarkdown { } } - static Widget _buildNetworkImage( + Widget _buildNetworkImage( String url, BuildContext context, ConduitThemeExtension theme, @@ -387,7 +461,7 @@ class ConduitMarkdown { ); } - static Widget _buildImageError( + Widget _buildImageError( BuildContext context, ConduitThemeExtension theme, ) { @@ -408,78 +482,95 @@ class ConduitMarkdown { } } -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, - ), - ), - ); +// 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); + } } -Widget _buildUnsupportedMermaidContainer({ - required BuildContext context, - required ConduitThemeExtension conduitTheme, - required String code, -}) { - final textStyle = AppTypography.bodySmallStyle.copyWith( - color: conduitTheme.codeText.withValues(alpha: 0.7), - ); +// LaTeX builder +class _LatexBuilder extends MarkdownElementBuilder { + _LatexBuilder(this.context); - 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), - ), - ], - ), - ); + 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; + } + + return Padding( + padding: const EdgeInsets.symmetric(vertical: Spacing.xs), + child: Center(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; + } +} + +// Mermaid diagram WebView widget class MermaidDiagram extends StatefulWidget { const MermaidDiagram({ super.key, diff --git a/lib/shared/widgets/markdown/markdown_latex.dart b/lib/shared/widgets/markdown/markdown_latex.dart deleted file mode 100644 index 6dcf04a..0000000 --- a/lib/shared/widgets/markdown/markdown_latex.dart +++ /dev/null @@ -1,123 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:markdown/markdown.dart' as m; -import 'package:markdown_widget/markdown_widget.dart'; - -import 'latex_block_widget.dart'; - -const String _latexTag = 'latex'; - -/// Provides LaTeX parsing support for markdown_widget. -class ConduitLatex { - const ConduitLatex(); - - /// Returns the inline syntax used to identify LaTeX segments. - List syntaxes() => [ - _LatexDollarSyntax(), - _LatexEscapedSyntax(), - ]; - - /// Returns the span generator that renders LaTeX expressions. - SpanNodeGeneratorWithTag generator({required bool isDark}) { - return SpanNodeGeneratorWithTag( - tag: _latexTag, - generator: (element, config, visitor) { - return _LatexNode( - attributes: element.attributes, - rawText: element.textContent, - config: config, - isDark: isDark, - ); - }, - ); - } -} - -class _LatexDollarSyntax extends m.InlineSyntax { - _LatexDollarSyntax() - : super( - r'(\$\$[\s\S]+?\$\$)|(\$[^\n]+?\$)', - startCharacter: r'$'.codeUnitAt(0), - ); - - @override - bool onMatch(m.InlineParser parser, Match match) { - return _handleMatch(parser, match.input.substring(match.start, match.end)); - } -} - -class _LatexEscapedSyntax extends m.InlineSyntax { - _LatexEscapedSyntax() - : super( - r'(\\\\\([\s\S]+?\\\\\))|(\\\\\[[\s\S]+?\\\\\])', - startCharacter: r'\'.codeUnitAt(0), - ); - - @override - bool onMatch(m.InlineParser parser, Match match) { - return _handleMatch(parser, match.input.substring(match.start, match.end)); - } -} - -bool _handleMatch(m.InlineParser parser, String raw) { - final element = m.Element.text(_latexTag, raw); - String content = raw; - var 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; - } - - element.attributes['content'] = content; - element.attributes['isInline'] = '$isInline'; - parser.addNode(element); - return true; -} - -class _LatexNode extends SpanNode { - _LatexNode({ - required this.attributes, - required this.rawText, - required this.config, - required this.isDark, - }); - - final Map attributes; - final String rawText; - final MarkdownConfig config; - final bool isDark; - - @override - InlineSpan build() { - final content = attributes['content']?.trim(); - final isInline = attributes['isInline'] == 'true'; - final baseStyle = (parentStyle ?? config.p.textStyle).copyWith( - color: - (parentStyle ?? config.p.textStyle).color ?? - (isDark ? Colors.white : Colors.black), - ); - - if (content == null || content.isEmpty) { - return TextSpan(text: rawText, style: baseStyle); - } - - return WidgetSpan( - alignment: PlaceholderAlignment.middle, - child: LatexBlockWidget( - content: content, - isInline: isInline, - style: baseStyle, - isDark: isDark, - ), - ); - } -} diff --git a/lib/shared/widgets/markdown/streaming_markdown_widget.dart b/lib/shared/widgets/markdown/streaming_markdown_widget.dart index a583b3b..06ed932 100644 --- a/lib/shared/widgets/markdown/streaming_markdown_widget.dart +++ b/lib/shared/widgets/markdown/streaming_markdown_widget.dart @@ -1,7 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:markdown_widget/markdown_widget.dart'; - import '../../theme/theme_extensions.dart'; import 'markdown_config.dart'; import 'markdown_preprocessor.dart'; @@ -27,17 +25,13 @@ class StreamingMarkdownWidget extends StatelessWidget { final normalized = ConduitMarkdownPreprocessor.normalize(content); final mermaidRegex = RegExp(r'```mermaid\s*([\s\S]*?)```', multiLine: true); final matches = mermaidRegex.allMatches(normalized).toList(); - final renderComponents = ConduitMarkdown.prepare( - context, - onTapLink: onTapLink, - ); Widget buildMarkdown(String data) { - return MarkdownBlock( + return ConduitMarkdown.buildBlock( + context: context, data: data, + onTapLink: onTapLink, selectable: false, - config: renderComponents.config, - generator: renderComponents.generator, ); } diff --git a/pubspec.lock b/pubspec.lock index fe442a8..210e332 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -451,6 +451,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_markdown_plus: + dependency: "direct main" + description: + name: flutter_markdown_plus + sha256: "7f349c075157816da399216a4127096108fd08e1ac931e34e72899281db4113c" + url: "https://pub.dev" + source: hosted + version: "1.0.5" flutter_math_fork: dependency: "direct main" description: @@ -845,14 +853,6 @@ packages: url: "https://pub.dev" source: hosted version: "7.3.0" - markdown_widget: - dependency: "direct main" - description: - name: markdown_widget - sha256: b52c13d3ee4d0e60c812e15b0593f142a3b8a2003cde1babb271d001a1dbdc1c - url: "https://pub.dev" - source: hosted - version: "2.3.2+8" matcher: dependency: transitive description: @@ -1181,14 +1181,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.28.0" - scroll_to_index: - dependency: transitive - description: - name: scroll_to_index - sha256: b707546e7500d9f070d63e5acf74fd437ec7eeeb68d3412ef7b0afada0b4f176 - url: "https://pub.dev" - source: hosted - version: "3.0.1" share_handler: dependency: "direct main" description: @@ -1674,14 +1666,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" - visibility_detector: - dependency: transitive - description: - name: visibility_detector - sha256: dd5cc11e13494f432d15939c3aa8ae76844c42b723398643ce9addb88a5ed420 - url: "https://pub.dev" - source: hosted - version: "0.4.0+2" vm_service: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 77a05aa..689cc14 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -29,12 +29,15 @@ dependencies: hive_ce_flutter: ^2.3.2 shared_preferences: ^2.3.2 - # UI Components - GPT Markdown + # UI Components - Markdown Rendering cached_network_image: ^3.3.1 flutter_highlight: ^0.7.0 + flutter_markdown_plus: ^1.0.5 + markdown: ^7.3.0 webview_flutter: ^4.7.0 socket_io_client: ^3.1.2 yaml: ^3.1.2 + flutter_math_fork: ^0.7.4 @@ -65,9 +68,6 @@ dependencies: share_plus: ^12.0.0 share_handler: ^0.0.19 riverpod_annotation: ^3.0.0 - markdown_widget: ^2.3.2+8 - flutter_math_fork: ^0.7.4 - markdown: ^7.3.0 # Clipboard functionality is available through flutter/services (part of Flutter SDK)