feat(markdown): Add support for rendering ChartJS blocks in markdown
This commit is contained in:
@@ -259,6 +259,87 @@ class ConduitMarkdown {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Checks if HTML content contains ChartJS code patterns.
|
||||
static bool containsChartJs(String html) {
|
||||
return html.contains('new Chart(') || html.contains('Chart.');
|
||||
}
|
||||
|
||||
/// Builds a ChartJS block for rendering in a WebView.
|
||||
static Widget buildChartJsBlock(BuildContext context, String htmlContent) {
|
||||
final conduitTheme = context.conduitTheme;
|
||||
final materialTheme = Theme.of(context);
|
||||
|
||||
if (ChartJsDiagram.isSupported) {
|
||||
return _buildChartJsContainer(
|
||||
context: context,
|
||||
conduitTheme: conduitTheme,
|
||||
materialTheme: materialTheme,
|
||||
htmlContent: htmlContent,
|
||||
);
|
||||
}
|
||||
|
||||
return _buildUnsupportedChartJsContainer(
|
||||
context: context,
|
||||
conduitTheme: conduitTheme,
|
||||
);
|
||||
}
|
||||
|
||||
static Widget _buildChartJsContainer({
|
||||
required BuildContext context,
|
||||
required ConduitThemeExtension conduitTheme,
|
||||
required ThemeData materialTheme,
|
||||
required String htmlContent,
|
||||
}) {
|
||||
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: 320,
|
||||
width: double.infinity,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(AppBorderRadius.sm),
|
||||
child: ChartJsDiagram(
|
||||
htmlContent: htmlContent,
|
||||
brightness: materialTheme.brightness,
|
||||
colorScheme: materialTheme.colorScheme,
|
||||
tokens: tokens,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static Widget _buildUnsupportedChartJsContainer({
|
||||
required BuildContext context,
|
||||
required ConduitThemeExtension conduitTheme,
|
||||
}) {
|
||||
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: Text(
|
||||
'Chart preview is not available on this platform.',
|
||||
style: textStyle,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Code syntax highlighting
|
||||
@@ -554,6 +635,262 @@ class _LatexInlineSyntax extends md.InlineSyntax {
|
||||
}
|
||||
}
|
||||
|
||||
// ChartJS diagram WebView widget
|
||||
class ChartJsDiagram extends StatefulWidget {
|
||||
const ChartJsDiagram({
|
||||
super.key,
|
||||
required this.htmlContent,
|
||||
required this.brightness,
|
||||
required this.colorScheme,
|
||||
required this.tokens,
|
||||
});
|
||||
|
||||
final String htmlContent;
|
||||
final Brightness brightness;
|
||||
final ColorScheme colorScheme;
|
||||
final AppColorTokens tokens;
|
||||
|
||||
static bool get isSupported => !kIsWeb;
|
||||
|
||||
static Future<String> _loadScript() {
|
||||
return _scriptFuture ??= rootBundle.loadString('assets/chartjs.min.js');
|
||||
}
|
||||
|
||||
static Future<String>? _scriptFuture;
|
||||
|
||||
@override
|
||||
State<ChartJsDiagram> createState() => _ChartJsDiagramState();
|
||||
}
|
||||
|
||||
class _ChartJsDiagramState extends State<ChartJsDiagram> {
|
||||
WebViewController? _controller;
|
||||
String? _script;
|
||||
final Set<Factory<OneSequenceGestureRecognizer>> _gestureRecognizers =
|
||||
<Factory<OneSequenceGestureRecognizer>>{
|
||||
Factory<OneSequenceGestureRecognizer>(() => EagerGestureRecognizer()),
|
||||
};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (!ChartJsDiagram.isSupported) {
|
||||
return;
|
||||
}
|
||||
ChartJsDiagram._loadScript().then((value) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
_script = value;
|
||||
_controller = WebViewController()
|
||||
..setJavaScriptMode(JavaScriptMode.unrestricted)
|
||||
..setBackgroundColor(Colors.transparent);
|
||||
_loadHtml();
|
||||
setState(() {});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(ChartJsDiagram oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (_controller == null || _script == null) {
|
||||
return;
|
||||
}
|
||||
final contentChanged = oldWidget.htmlContent != widget.htmlContent;
|
||||
final themeChanged =
|
||||
oldWidget.brightness != widget.brightness ||
|
||||
oldWidget.colorScheme != widget.colorScheme ||
|
||||
oldWidget.tokens != widget.tokens;
|
||||
if (contentChanged || 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.htmlContent, _script!));
|
||||
}
|
||||
|
||||
String _buildHtml(String htmlContent, String script) {
|
||||
final isDark = widget.brightness == Brightness.dark;
|
||||
final background = _toHex(
|
||||
isDark ? widget.tokens.codeBackground : Colors.white,
|
||||
);
|
||||
final textColor = _toHex(widget.tokens.codeText);
|
||||
final gridColor = _toHex(
|
||||
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 '''
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
html, body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: $background;
|
||||
color: $textColor;
|
||||
overflow: hidden;
|
||||
}
|
||||
#chart-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 8px;
|
||||
}
|
||||
canvas {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="chart-container">
|
||||
<canvas id="chart-canvas"></canvas>
|
||||
</div>
|
||||
<script>$script</script>
|
||||
<script>
|
||||
(function() {
|
||||
// Configure Chart.js defaults for the theme
|
||||
Chart.defaults.color = '$textColor';
|
||||
Chart.defaults.borderColor = '$gridColor';
|
||||
Chart.defaults.backgroundColor = '$background';
|
||||
|
||||
// Extract chart configuration from the HTML content and create the chart
|
||||
try {
|
||||
const htmlContent = ${jsonEncode(htmlContent)};
|
||||
|
||||
// Look for chart configuration in the HTML
|
||||
// Pattern 1: new Chart(ctx, config) - extract the config
|
||||
const chartMatch = htmlContent.match(/new\\s+Chart\\s*\\([^,]+,\\s*([\\s\\S]*?)\\)\\s*;?\\s*(?:<\\/script>|\$)/);
|
||||
|
||||
if (chartMatch) {
|
||||
// Try to extract and evaluate the config
|
||||
let configStr = chartMatch[1].trim();
|
||||
|
||||
// Only apply brace-counting extraction if config starts with '{' (object literal)
|
||||
// For variable references (myConfig) or function calls (getConfig()), use the full string
|
||||
if (configStr.startsWith('{')) {
|
||||
// Clean up the config string - remove trailing content after the config object
|
||||
// This parser properly tracks string literals to avoid matching braces inside strings
|
||||
let braceCount = 0;
|
||||
let endIndex = 0;
|
||||
let inString = null; // null, "'", '"', or '`'
|
||||
let escaped = false;
|
||||
|
||||
for (let i = 0; i < configStr.length; i++) {
|
||||
const char = configStr[i];
|
||||
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '\\\\' && inString) {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle string delimiters
|
||||
if (!inString && (char === "'" || char === '"' || char === '`')) {
|
||||
inString = char;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inString && char === inString) {
|
||||
inString = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Only count braces when not inside a string
|
||||
if (!inString) {
|
||||
if (char === '{') braceCount++;
|
||||
else if (char === '}') braceCount--;
|
||||
|
||||
if (braceCount === 0 && i > 0) {
|
||||
endIndex = i + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (endIndex > 0) {
|
||||
configStr = configStr.substring(0, endIndex);
|
||||
}
|
||||
}
|
||||
|
||||
// Evaluate the config
|
||||
const config = eval('(' + configStr + ')');
|
||||
|
||||
// Create the chart
|
||||
const ctx = document.getElementById('chart-canvas').getContext('2d');
|
||||
new Chart(ctx, config);
|
||||
} else {
|
||||
// Fallback: try to find any canvas element and chart script
|
||||
console.log('Could not find Chart constructor pattern');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error creating chart:', e);
|
||||
document.getElementById('chart-container').innerHTML =
|
||||
'<p style="color: red; padding: 16px;">Error rendering chart: ' + e.message + '</p>';
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
''';
|
||||
}
|
||||
|
||||
String _toHex(Color color) {
|
||||
int channel(double value) {
|
||||
final scaled = (value * 255).round();
|
||||
if (scaled < 0) {
|
||||
return 0;
|
||||
}
|
||||
if (scaled > 255) {
|
||||
return 255;
|
||||
}
|
||||
return scaled;
|
||||
}
|
||||
|
||||
// CSS 8-digit hex uses RGBA format (#RRGGBBAA), not ARGB
|
||||
final rgba =
|
||||
(channel(color.r) << 24) |
|
||||
(channel(color.g) << 16) |
|
||||
(channel(color.b) << 8) |
|
||||
channel(color.a);
|
||||
return '#${rgba.toRadixString(16).padLeft(8, '0')}';
|
||||
}
|
||||
}
|
||||
|
||||
// Mermaid diagram WebView widget
|
||||
class MermaidDiagram extends StatefulWidget {
|
||||
const MermaidDiagram({
|
||||
|
||||
@@ -7,6 +7,9 @@ import 'markdown_preprocessor.dart';
|
||||
// Pre-compiled regex for mermaid diagram detection (performance optimization)
|
||||
final _mermaidRegex = RegExp(r'```mermaid\s*([\s\S]*?)```', multiLine: true);
|
||||
|
||||
// Pre-compiled regex for HTML code blocks that may contain ChartJS
|
||||
final _htmlBlockRegex = RegExp(r'```html\s*([\s\S]*?)```', multiLine: true);
|
||||
|
||||
class StreamingMarkdownWidget extends StatelessWidget {
|
||||
const StreamingMarkdownWidget({
|
||||
super.key,
|
||||
@@ -29,7 +32,42 @@ class StreamingMarkdownWidget extends StatelessWidget {
|
||||
}
|
||||
|
||||
final normalized = ConduitMarkdownPreprocessor.normalize(content);
|
||||
final matches = _mermaidRegex.allMatches(normalized).toList();
|
||||
|
||||
// Collect all special blocks (Mermaid and ChartJS)
|
||||
final specialBlocks = <_SpecialBlock>[];
|
||||
|
||||
// Find mermaid blocks
|
||||
for (final match in _mermaidRegex.allMatches(normalized)) {
|
||||
final code = match.group(1)?.trim() ?? '';
|
||||
if (code.isNotEmpty) {
|
||||
specialBlocks.add(
|
||||
_SpecialBlock(
|
||||
start: match.start,
|
||||
end: match.end,
|
||||
type: _BlockType.mermaid,
|
||||
content: code,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Find HTML blocks that contain ChartJS
|
||||
for (final match in _htmlBlockRegex.allMatches(normalized)) {
|
||||
final html = match.group(1)?.trim() ?? '';
|
||||
if (html.isNotEmpty && ConduitMarkdown.containsChartJs(html)) {
|
||||
specialBlocks.add(
|
||||
_SpecialBlock(
|
||||
start: match.start,
|
||||
end: match.end,
|
||||
type: _BlockType.chartJs,
|
||||
content: html,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by position
|
||||
specialBlocks.sort((a, b) => a.start.compareTo(b.start));
|
||||
|
||||
Widget buildMarkdown(String data) {
|
||||
return ConduitMarkdown.buildBlock(
|
||||
@@ -41,7 +79,7 @@ class StreamingMarkdownWidget extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
if (matches.isEmpty) {
|
||||
if (specialBlocks.isEmpty) {
|
||||
return SelectionArea(
|
||||
child: Theme(
|
||||
data: Theme.of(context).copyWith(
|
||||
@@ -56,18 +94,27 @@ class StreamingMarkdownWidget extends StatelessWidget {
|
||||
|
||||
final children = <Widget>[];
|
||||
var currentIndex = 0;
|
||||
for (final match in matches) {
|
||||
final before = normalized.substring(currentIndex, match.start);
|
||||
for (final block in specialBlocks) {
|
||||
// Skip overlapping blocks
|
||||
if (block.start < currentIndex) continue;
|
||||
|
||||
final before = normalized.substring(currentIndex, block.start);
|
||||
if (before.trim().isNotEmpty) {
|
||||
children.add(buildMarkdown(before));
|
||||
}
|
||||
|
||||
final code = match.group(1)?.trim() ?? '';
|
||||
if (code.isNotEmpty) {
|
||||
children.add(ConduitMarkdown.buildMermaidBlock(context, code));
|
||||
switch (block.type) {
|
||||
case _BlockType.mermaid:
|
||||
children.add(
|
||||
ConduitMarkdown.buildMermaidBlock(context, block.content),
|
||||
);
|
||||
case _BlockType.chartJs:
|
||||
children.add(
|
||||
ConduitMarkdown.buildChartJsBlock(context, block.content),
|
||||
);
|
||||
}
|
||||
|
||||
currentIndex = match.end;
|
||||
currentIndex = block.end;
|
||||
}
|
||||
|
||||
final tail = normalized.substring(currentIndex);
|
||||
@@ -91,6 +138,24 @@ class StreamingMarkdownWidget extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Types of special blocks that need custom rendering
|
||||
enum _BlockType { mermaid, chartJs }
|
||||
|
||||
/// Represents a special block in the content
|
||||
class _SpecialBlock {
|
||||
final int start;
|
||||
final int end;
|
||||
final _BlockType type;
|
||||
final String content;
|
||||
|
||||
const _SpecialBlock({
|
||||
required this.start,
|
||||
required this.end,
|
||||
required this.type,
|
||||
required this.content,
|
||||
});
|
||||
}
|
||||
|
||||
extension StreamingMarkdownExtension on String {
|
||||
Widget toMarkdown({
|
||||
required BuildContext context,
|
||||
|
||||
Reference in New Issue
Block a user