refactor: update markdown dependencies and enhance rendering capabilities

- Added flutter_markdown_plus as a new dependency for improved markdown rendering.
- Removed deprecated markdown_widget and related LaTeX handling, streamlining the codebase.
- Updated the ConduitMarkdown class to utilize the new markdown processing logic, enhancing maintainability.
- Improved the StreamingMarkdownWidget to leverage the updated markdown rendering methods, ensuring a cohesive user experience.
- Enhanced support for LaTeX and Mermaid diagrams within markdown content, providing better visual representation.
This commit is contained in:
cogwheel0
2025-10-04 23:05:03 +05:30
parent 758ed411b0
commit a4319a1d9e
6 changed files with 396 additions and 491 deletions

View File

@@ -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),
);
}
}

View File

@@ -9,20 +9,21 @@ import 'package:flutter/services.dart';
import 'package:flutter_highlight/flutter_highlight.dart'; import 'package:flutter_highlight/flutter_highlight.dart';
import 'package:flutter_highlight/themes/a11y-dark.dart'; import 'package:flutter_highlight/themes/a11y-dark.dart';
import 'package:flutter_highlight/themes/a11y-light.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 'package:webview_flutter/webview_flutter.dart';
import '../../theme/color_tokens.dart'; import '../../theme/color_tokens.dart';
import '../../theme/theme_extensions.dart'; import '../../theme/theme_extensions.dart';
import 'code_block_header.dart'; import 'code_block_header.dart';
import 'markdown_latex.dart';
typedef MarkdownLinkTapCallback = void Function(String url, String title); typedef MarkdownLinkTapCallback = void Function(String url, String title);
class ConduitMarkdown { class ConduitMarkdown {
const ConduitMarkdown._(); const ConduitMarkdown._();
static MarkdownWidget build({ static Widget build({
required BuildContext context, required BuildContext context,
required String data, required String data,
MarkdownLinkTapCallback? onTapLink, MarkdownLinkTapCallback? onTapLink,
@@ -30,49 +31,39 @@ class ConduitMarkdown {
bool shrinkWrap = false, bool shrinkWrap = false,
ScrollPhysics? physics, ScrollPhysics? physics,
}) { }) {
final components = prepare(context, onTapLink: onTapLink); return MarkdownBody(
return MarkdownWidget(
data: data, data: data,
selectable: selectable, selectable: selectable,
config: components.config,
markdownGenerator: components.generator,
shrinkWrap: shrinkWrap, shrinkWrap: shrinkWrap,
physics: physics, styleSheet: _buildStyleSheet(context),
padding: EdgeInsets.zero, 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 BuildContext context,
required String data, required String data,
MarkdownLinkTapCallback? onTapLink, MarkdownLinkTapCallback? onTapLink,
bool selectable = true, bool selectable = true,
}) { }) {
final components = prepare(context, onTapLink: onTapLink); return build(
return MarkdownBlock( context: context,
data: data, data: data,
onTapLink: onTapLink,
selectable: selectable, selectable: selectable,
config: components.config, shrinkWrap: true,
generator: components.generator,
); );
} }
static ({MarkdownConfig config, MarkdownGenerator generator}) prepare( static MarkdownStyleSheet _buildStyleSheet(BuildContext context) {
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,
}) {
final theme = context.conduitTheme; final theme = context.conduitTheme;
final material = Theme.of(context); final material = Theme.of(context);
final isDark = material.brightness == Brightness.dark;
final baseBody = AppTypography.bodyMediumStyle.copyWith( final baseBody = AppTypography.bodyMediumStyle.copyWith(
color: theme.textPrimary, color: theme.textPrimary,
@@ -85,209 +76,89 @@ class ConduitMarkdown {
final codeBackground = theme.surfaceContainer.withValues(alpha: 0.55); final codeBackground = theme.surfaceContainer.withValues(alpha: 0.55);
final borderColor = theme.cardBorder.withValues(alpha: 0.25); final borderColor = theme.cardBorder.withValues(alpha: 0.25);
final highlightTheme = _codeHighlightTheme(theme, isDark: isDark);
return MarkdownConfig( return MarkdownStyleSheet(
configs: [ p: baseBody,
PConfig(textStyle: baseBody), h1: AppTypography.headlineLargeStyle.copyWith(
H1Config( color: theme.textPrimary,
style: 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( blockquotePadding: const EdgeInsets.symmetric(
color: theme.textPrimary, 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) { static Map<String, MarkdownElementBuilder> _buildCustomBuilders(
final isDark = Theme.of(context).brightness == Brightness.dark; BuildContext context,
final latex = ConduitLatex(); MarkdownLinkTapCallback? onTapLink,
return MarkdownGenerator( ) {
inlineSyntaxList: latex.syntaxes(),
generators: [latex.generator(isDark: isDark)],
linesMargin: const EdgeInsets.symmetric(vertical: Spacing.xs),
);
}
static Map<String, TextStyle> _codeHighlightTheme(
ConduitThemeExtension theme, {
required bool isDark,
}) {
final baseTheme = isDark ? a11yDarkTheme : a11yLightTheme;
final codeStyle = AppTypography.codeStyle.copyWith(color: theme.codeText);
return { return {
for (final entry in baseTheme.entries) 'code': _CodeBlockBuilder(context),
entry.key: entry.value.copyWith( 'img': _ImageBuilder(context),
color: entry.value.color ?? theme.codeText, 'mermaid': _MermaidBuilder(context),
fontFamily: AppTypography.monospaceFontFamily, 'latex': _LatexBuilder(context),
fontSize: codeStyle.fontSize,
height: codeStyle.height,
),
}; };
} }
static Widget _buildCodeWrapper({ static List<md.InlineSyntax> _buildInlineSyntaxes() {
required BuildContext context, return [
required Widget child, _LatexInlineSyntax(),
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 Widget buildMermaidBlock(BuildContext context, String code) { 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; 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<String, TextStyle> _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') { if (uri.scheme == 'data') {
return _buildBase64Image(uri.toString(), context, theme); return _buildBase64Image(uri.toString(), context, theme);
} }
@@ -323,7 +397,7 @@ class ConduitMarkdown {
return _buildImageError(context, theme); return _buildImageError(context, theme);
} }
static Widget _buildBase64Image( Widget _buildBase64Image(
String dataUrl, String dataUrl,
BuildContext context, BuildContext context,
ConduitThemeExtension theme, ConduitThemeExtension theme,
@@ -356,7 +430,7 @@ class ConduitMarkdown {
} }
} }
static Widget _buildNetworkImage( Widget _buildNetworkImage(
String url, String url,
BuildContext context, BuildContext context,
ConduitThemeExtension theme, ConduitThemeExtension theme,
@@ -387,7 +461,7 @@ class ConduitMarkdown {
); );
} }
static Widget _buildImageError( Widget _buildImageError(
BuildContext context, BuildContext context,
ConduitThemeExtension theme, ConduitThemeExtension theme,
) { ) {
@@ -408,78 +482,95 @@ class ConduitMarkdown {
} }
} }
Widget _buildMermaidContainer({ // Mermaid diagram builder
required BuildContext context, class _MermaidBuilder extends MarkdownElementBuilder {
required ConduitThemeExtension conduitTheme, _MermaidBuilder(this.context);
required ThemeData materialTheme,
required String code, final BuildContext context;
}) {
final tokens = context.colorTokens; @override
return Container( Widget? visitElementAfter(md.Element element, TextStyle? preferredStyle) {
margin: const EdgeInsets.symmetric(vertical: Spacing.sm), final code = element.textContent;
decoration: BoxDecoration( return ConduitMarkdown.buildMermaidBlock(context, code);
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({ // LaTeX builder
required BuildContext context, class _LatexBuilder extends MarkdownElementBuilder {
required ConduitThemeExtension conduitTheme, _LatexBuilder(this.context);
required String code,
}) {
final textStyle = AppTypography.bodySmallStyle.copyWith(
color: conduitTheme.codeText.withValues(alpha: 0.7),
);
return Container( final BuildContext context;
margin: const EdgeInsets.symmetric(vertical: Spacing.sm),
padding: const EdgeInsets.all(Spacing.sm), @override
decoration: BoxDecoration( Widget? visitElementAfter(md.Element element, TextStyle? preferredStyle) {
color: conduitTheme.surfaceContainer.withValues(alpha: 0.35), final isDark = Theme.of(context).brightness == Brightness.dark;
borderRadius: BorderRadius.circular(AppBorderRadius.sm), final content = element.textContent.trim();
border: Border.all( final isInline = element.attributes['isInline'] == 'true';
color: conduitTheme.cardBorder.withValues(alpha: 0.4),
width: BorderWidth.micro, final baseStyle = (preferredStyle ?? AppTypography.bodyMediumStyle).copyWith(
), color: isDark ? Colors.white : Colors.black,
), );
child: Column(
crossAxisAlignment: CrossAxisAlignment.start, if (content.isEmpty) {
mainAxisSize: MainAxisSize.min, return Text(element.textContent, style: baseStyle);
children: [ }
Text(
'Mermaid preview is not available on this platform.', final mathWidget = Math.tex(
style: textStyle, content,
), mathStyle: MathStyle.text,
const SizedBox(height: Spacing.xs), textStyle: baseStyle,
SelectableText( textScaleFactor: 1,
code, onErrorFallback: (error) {
maxLines: null, return Text(content, style: baseStyle.copyWith(color: Colors.red));
textAlign: TextAlign.left, },
textDirection: TextDirection.ltr, );
textWidthBasis: TextWidthBasis.parent,
style: AppTypography.codeStyle.copyWith(color: conduitTheme.codeText), 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 { class MermaidDiagram extends StatefulWidget {
const MermaidDiagram({ const MermaidDiagram({
super.key, super.key,

View File

@@ -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<m.InlineSyntax> 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<String, String> 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,
),
);
}
}

View File

@@ -1,7 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:markdown_widget/markdown_widget.dart';
import '../../theme/theme_extensions.dart'; import '../../theme/theme_extensions.dart';
import 'markdown_config.dart'; import 'markdown_config.dart';
import 'markdown_preprocessor.dart'; import 'markdown_preprocessor.dart';
@@ -27,17 +25,13 @@ class StreamingMarkdownWidget extends StatelessWidget {
final normalized = ConduitMarkdownPreprocessor.normalize(content); final normalized = ConduitMarkdownPreprocessor.normalize(content);
final mermaidRegex = RegExp(r'```mermaid\s*([\s\S]*?)```', multiLine: true); final mermaidRegex = RegExp(r'```mermaid\s*([\s\S]*?)```', multiLine: true);
final matches = mermaidRegex.allMatches(normalized).toList(); final matches = mermaidRegex.allMatches(normalized).toList();
final renderComponents = ConduitMarkdown.prepare(
context,
onTapLink: onTapLink,
);
Widget buildMarkdown(String data) { Widget buildMarkdown(String data) {
return MarkdownBlock( return ConduitMarkdown.buildBlock(
context: context,
data: data, data: data,
onTapLink: onTapLink,
selectable: false, selectable: false,
config: renderComponents.config,
generator: renderComponents.generator,
); );
} }

View File

@@ -451,6 +451,14 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" 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: flutter_math_fork:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -845,14 +853,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "7.3.0" 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: matcher:
dependency: transitive dependency: transitive
description: description:
@@ -1181,14 +1181,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.28.0" 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: share_handler:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -1674,14 +1666,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.2.0" 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: vm_service:
dependency: transitive dependency: transitive
description: description:

View File

@@ -29,12 +29,15 @@ dependencies:
hive_ce_flutter: ^2.3.2 hive_ce_flutter: ^2.3.2
shared_preferences: ^2.3.2 shared_preferences: ^2.3.2
# UI Components - GPT Markdown # UI Components - Markdown Rendering
cached_network_image: ^3.3.1 cached_network_image: ^3.3.1
flutter_highlight: ^0.7.0 flutter_highlight: ^0.7.0
flutter_markdown_plus: ^1.0.5
markdown: ^7.3.0
webview_flutter: ^4.7.0 webview_flutter: ^4.7.0
socket_io_client: ^3.1.2 socket_io_client: ^3.1.2
yaml: ^3.1.2 yaml: ^3.1.2
flutter_math_fork: ^0.7.4
@@ -65,9 +68,6 @@ dependencies:
share_plus: ^12.0.0 share_plus: ^12.0.0
share_handler: ^0.0.19 share_handler: ^0.0.19
riverpod_annotation: ^3.0.0 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) # Clipboard functionality is available through flutter/services (part of Flutter SDK)