feat(markdown): Add support for rendering ChartJS blocks in markdown
This commit is contained in:
14
assets/chartjs.min.js
vendored
Normal file
14
assets/chartjs.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -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
|
// 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
|
// Mermaid diagram WebView widget
|
||||||
class MermaidDiagram extends StatefulWidget {
|
class MermaidDiagram extends StatefulWidget {
|
||||||
const MermaidDiagram({
|
const MermaidDiagram({
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ import 'markdown_preprocessor.dart';
|
|||||||
// Pre-compiled regex for mermaid diagram detection (performance optimization)
|
// Pre-compiled regex for mermaid diagram detection (performance optimization)
|
||||||
final _mermaidRegex = RegExp(r'```mermaid\s*([\s\S]*?)```', multiLine: true);
|
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 {
|
class StreamingMarkdownWidget extends StatelessWidget {
|
||||||
const StreamingMarkdownWidget({
|
const StreamingMarkdownWidget({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -29,7 +32,42 @@ class StreamingMarkdownWidget extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final normalized = ConduitMarkdownPreprocessor.normalize(content);
|
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) {
|
Widget buildMarkdown(String data) {
|
||||||
return ConduitMarkdown.buildBlock(
|
return ConduitMarkdown.buildBlock(
|
||||||
@@ -41,7 +79,7 @@ class StreamingMarkdownWidget extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (matches.isEmpty) {
|
if (specialBlocks.isEmpty) {
|
||||||
return SelectionArea(
|
return SelectionArea(
|
||||||
child: Theme(
|
child: Theme(
|
||||||
data: Theme.of(context).copyWith(
|
data: Theme.of(context).copyWith(
|
||||||
@@ -56,18 +94,27 @@ class StreamingMarkdownWidget extends StatelessWidget {
|
|||||||
|
|
||||||
final children = <Widget>[];
|
final children = <Widget>[];
|
||||||
var currentIndex = 0;
|
var currentIndex = 0;
|
||||||
for (final match in matches) {
|
for (final block in specialBlocks) {
|
||||||
final before = normalized.substring(currentIndex, match.start);
|
// Skip overlapping blocks
|
||||||
|
if (block.start < currentIndex) continue;
|
||||||
|
|
||||||
|
final before = normalized.substring(currentIndex, block.start);
|
||||||
if (before.trim().isNotEmpty) {
|
if (before.trim().isNotEmpty) {
|
||||||
children.add(buildMarkdown(before));
|
children.add(buildMarkdown(before));
|
||||||
}
|
}
|
||||||
|
|
||||||
final code = match.group(1)?.trim() ?? '';
|
switch (block.type) {
|
||||||
if (code.isNotEmpty) {
|
case _BlockType.mermaid:
|
||||||
children.add(ConduitMarkdown.buildMermaidBlock(context, code));
|
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);
|
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 {
|
extension StreamingMarkdownExtension on String {
|
||||||
Widget toMarkdown({
|
Widget toMarkdown({
|
||||||
required BuildContext context,
|
required BuildContext context,
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ flutter:
|
|||||||
assets:
|
assets:
|
||||||
- assets/icons/
|
- assets/icons/
|
||||||
- assets/mermaid.min.js
|
- assets/mermaid.min.js
|
||||||
|
- assets/chartjs.min.js
|
||||||
|
|
||||||
flutter_native_splash:
|
flutter_native_splash:
|
||||||
# Splash background matches the light theme; `color_dark` handles dark mode.
|
# Splash background matches the light theme; `color_dark` handles dark mode.
|
||||||
|
|||||||
Reference in New Issue
Block a user