overhaul ClideMarkdown renderer
Inline grouping collapses consecutive inline nodes (strong, em, code, a, del) into a single RichText widget instead of rendering each as a separate block — fixes tight list items like `builtin.git` appearing on its own line. HTML entity unescaping moved from pre-parse (useless) to post-AST extraction where the markdown package actually introduces the entities. Explicit Josefin Sans Light font family and 1.25 line height on all RichText spans. Body text bumped to 16px; inline code uses clideFontMono for visual weight parity. Adds onRecordTap callback — markdown links matching [DQRT]-\d+ render as tappable WidgetSpans with hover underline. New typography constants clideFontSmall (12), clideFontBadge (11), clideLineHeight (1.25). Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -3,14 +3,22 @@ import 'package:clide/kernel/src/theme/tokens.dart';
|
||||
import 'package:clide/widgets/src/clide_code_block.dart';
|
||||
import 'package:clide/widgets/src/clide_divider.dart';
|
||||
import 'package:clide/widgets/src/clide_text.dart';
|
||||
import 'package:clide/widgets/src/clide_tappable.dart';
|
||||
import 'package:clide/widgets/src/typography.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:markdown/markdown.dart' as md;
|
||||
|
||||
typedef RecordTapCallback = void Function(String id);
|
||||
|
||||
class ClideMarkdown extends StatelessWidget {
|
||||
const ClideMarkdown(this.source, {super.key});
|
||||
const ClideMarkdown(this.source, {super.key, this.onRecordTap});
|
||||
|
||||
static const double _fontSize = 16;
|
||||
static const double _lineHeight = clideLineHeight;
|
||||
static final _recordPattern = RegExp(r'^[DQRT]-\d+$');
|
||||
|
||||
final String source;
|
||||
final RecordTapCallback? onRecordTap;
|
||||
|
||||
static String _unescapeHtml(String s) {
|
||||
return s
|
||||
@@ -25,10 +33,9 @@ class ClideMarkdown extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final cleaned = _unescapeHtml(source);
|
||||
final doc = md.Document(extensionSet: md.ExtensionSet.gitHubFlavored);
|
||||
final nodes = doc.parseLines(cleaned.split('\n'));
|
||||
final widgets = _buildNodes(nodes, tokens);
|
||||
final nodes = doc.parseLines(source.split('\n'));
|
||||
final widgets = _buildNodes(nodes, tokens, onRecordTap);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -36,82 +43,122 @@ class ClideMarkdown extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
static List<Widget> _buildNodes(List<md.Node> nodes, SurfaceTokens tokens) {
|
||||
static const _inlineTags = {'strong', 'em', 'code', 'a', 'del', 'br', 'img', 'span'};
|
||||
|
||||
static bool _isInline(md.Node node) {
|
||||
if (node is md.Text) return true;
|
||||
if (node is md.Element) return _inlineTags.contains(node.tag);
|
||||
return false;
|
||||
}
|
||||
|
||||
static List<Widget> _buildNodes(List<md.Node> nodes, SurfaceTokens tokens, RecordTapCallback? onRecordTap) {
|
||||
final out = <Widget>[];
|
||||
final inlineRun = <md.Node>[];
|
||||
|
||||
void flushInline() {
|
||||
if (inlineRun.isEmpty) return;
|
||||
final spans = <InlineSpan>[];
|
||||
for (final n in inlineRun) {
|
||||
if (n is md.Text) {
|
||||
spans.add(TextSpan(text: _unescapeHtml(n.text)));
|
||||
} else if (n is md.Element) {
|
||||
spans.add(_inlineElementSpan(n, tokens, onRecordTap));
|
||||
}
|
||||
}
|
||||
out.add(RichText(
|
||||
text: TextSpan(
|
||||
style: TextStyle(
|
||||
fontFamily: clideUiFamily,
|
||||
fontFamilyFallback: clideUiFamilyFallback,
|
||||
fontWeight: clideUiDefaultWeight,
|
||||
color: tokens.globalForeground,
|
||||
fontSize: _fontSize,
|
||||
height: _lineHeight,
|
||||
),
|
||||
children: spans,
|
||||
),
|
||||
));
|
||||
inlineRun.clear();
|
||||
}
|
||||
|
||||
for (final node in nodes) {
|
||||
if (node is md.Element) {
|
||||
out.add(_buildElement(node, tokens));
|
||||
} else if (node is md.Text) {
|
||||
out.add(ClideText(node.text, fontSize: clideFontBody));
|
||||
if (_isInline(node)) {
|
||||
inlineRun.add(node);
|
||||
} else {
|
||||
flushInline();
|
||||
if (node is md.Element) {
|
||||
out.add(_buildElement(node, tokens, onRecordTap));
|
||||
}
|
||||
}
|
||||
}
|
||||
flushInline();
|
||||
return out;
|
||||
}
|
||||
|
||||
static Widget _buildElement(md.Element el, SurfaceTokens tokens) {
|
||||
static Widget _buildElement(md.Element el, SurfaceTokens tokens, RecordTapCallback? onRecordTap) {
|
||||
switch (el.tag) {
|
||||
case 'h1':
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 20, bottom: 10),
|
||||
child: _inlineText(el, tokens, fontSize: 22, fontWeight: FontWeight.w500),
|
||||
child: _inlineText(el, tokens, onRecordTap, fontSize: 22, fontWeight: FontWeight.w500),
|
||||
);
|
||||
case 'h2':
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 18, bottom: 8),
|
||||
child: _inlineText(el, tokens, fontSize: 18, fontWeight: FontWeight.w500),
|
||||
padding: const EdgeInsets.only(top: 18, bottom: 10),
|
||||
child: _inlineText(el, tokens, onRecordTap, fontSize: 18, fontWeight: FontWeight.w500),
|
||||
);
|
||||
case 'h3':
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 16, bottom: 6),
|
||||
child: _inlineText(el, tokens, fontSize: 16, fontWeight: FontWeight.w500),
|
||||
padding: const EdgeInsets.only(top: 16, bottom: 8),
|
||||
child: _inlineText(el, tokens, onRecordTap, fontSize: 16, fontWeight: FontWeight.w500),
|
||||
);
|
||||
case 'h4':
|
||||
case 'h5':
|
||||
case 'h6':
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 14, bottom: 6),
|
||||
child: _inlineText(el, tokens, fontSize: clideFontBody, fontWeight: FontWeight.w600),
|
||||
padding: const EdgeInsets.only(top: 14, bottom: 8),
|
||||
child: _inlineText(el, tokens, onRecordTap, fontSize: clideFontBody, fontWeight: FontWeight.w600),
|
||||
);
|
||||
case 'p':
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: _inlineRichText(el, tokens),
|
||||
padding: const EdgeInsets.only(bottom: 14),
|
||||
child: _inlineRichText(el, tokens, onRecordTap),
|
||||
);
|
||||
case 'ul':
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: 8, bottom: 12),
|
||||
padding: const EdgeInsets.only(left: 2, bottom: 14),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [for (final c in el.children ?? const []) if (c is md.Element) _buildListItem(c, tokens, ordered: false)],
|
||||
children: [for (final c in el.children ?? const []) if (c is md.Element) _buildListItem(c, tokens, onRecordTap, ordered: false)],
|
||||
),
|
||||
);
|
||||
case 'ol':
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: 8, bottom: 12),
|
||||
padding: const EdgeInsets.only(left: 2, bottom: 14),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (var i = 0; i < (el.children?.length ?? 0); i++)
|
||||
if (el.children![i] is md.Element) _buildListItem(el.children![i] as md.Element, tokens, ordered: true, index: i + 1),
|
||||
if (el.children![i] is md.Element) _buildListItem(el.children![i] as md.Element, tokens, onRecordTap, ordered: true, index: i + 1),
|
||||
],
|
||||
),
|
||||
);
|
||||
case 'blockquote':
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
padding: const EdgeInsets.only(left: 8),
|
||||
margin: const EdgeInsets.only(bottom: 14),
|
||||
padding: const EdgeInsets.only(left: 6),
|
||||
decoration: BoxDecoration(border: Border(left: BorderSide(color: tokens.globalTextMuted, width: 3))),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: _buildNodes(el.children?.cast<md.Node>() ?? const [], tokens),
|
||||
children: _buildNodes(el.children?.cast<md.Node>() ?? const [], tokens, onRecordTap),
|
||||
),
|
||||
);
|
||||
case 'pre':
|
||||
final codeEl = el.children?.whereType<md.Element>().firstOrNull;
|
||||
final code = codeEl?.textContent ?? el.textContent;
|
||||
final code = _unescapeHtml(codeEl?.textContent ?? el.textContent);
|
||||
String? lang;
|
||||
final cls = codeEl?.attributes['class'];
|
||||
if (cls != null && cls.startsWith('language-')) {
|
||||
@@ -126,26 +173,26 @@ class ClideMarkdown extends StatelessWidget {
|
||||
case 'table':
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: _buildTable(el, tokens),
|
||||
child: _buildTable(el, tokens, onRecordTap),
|
||||
);
|
||||
default:
|
||||
return _inlineRichText(el, tokens);
|
||||
return _inlineRichText(el, tokens, onRecordTap);
|
||||
}
|
||||
}
|
||||
|
||||
static Widget _buildListItem(md.Element el, SurfaceTokens tokens, {bool ordered = false, int index = 1}) {
|
||||
static Widget _buildListItem(md.Element el, SurfaceTokens tokens, RecordTapCallback? onRecordTap, {bool ordered = false, int index = 1}) {
|
||||
final bullet = ordered ? '$index. ' : '• ';
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 6),
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ClideText(bullet, color: tokens.globalTextMuted, fontSize: clideFontBody),
|
||||
ClideText(bullet, color: tokens.globalTextMuted, fontSize: _fontSize),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: _buildNodes(el.children?.cast<md.Node>() ?? const [], tokens),
|
||||
children: _buildNodes(el.children?.cast<md.Node>() ?? const [], tokens, onRecordTap),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -153,7 +200,7 @@ class ClideMarkdown extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
static Widget _buildTable(md.Element table, SurfaceTokens tokens) {
|
||||
static Widget _buildTable(md.Element table, SurfaceTokens tokens, RecordTapCallback? onRecordTap) {
|
||||
final rows = <TableRow>[];
|
||||
for (final child in table.children ?? const []) {
|
||||
if (child is! md.Element) continue;
|
||||
@@ -165,7 +212,7 @@ class ClideMarkdown extends StatelessWidget {
|
||||
if (cell is! md.Element) continue;
|
||||
cells.add(Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3),
|
||||
child: _inlineText(cell, tokens, fontWeight: isHeader ? FontWeight.w600 : null),
|
||||
child: _inlineText(cell, tokens, onRecordTap, fontWeight: isHeader ? FontWeight.w600 : null),
|
||||
));
|
||||
}
|
||||
if (cells.isNotEmpty) {
|
||||
@@ -184,62 +231,86 @@ class ClideMarkdown extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
static Widget _inlineText(md.Element el, SurfaceTokens tokens, {double? fontSize, FontWeight? fontWeight}) {
|
||||
return RichText(text: _buildInlineSpan(el, tokens, fontSize: fontSize, fontWeight: fontWeight));
|
||||
static Widget _inlineText(md.Element el, SurfaceTokens tokens, RecordTapCallback? onRecordTap, {double? fontSize, FontWeight? fontWeight}) {
|
||||
return RichText(text: _buildInlineSpan(el, tokens, onRecordTap, fontSize: fontSize, fontWeight: fontWeight));
|
||||
}
|
||||
|
||||
static Widget _inlineRichText(md.Element el, SurfaceTokens tokens) {
|
||||
return RichText(text: _buildInlineSpan(el, tokens));
|
||||
static Widget _inlineRichText(md.Element el, SurfaceTokens tokens, RecordTapCallback? onRecordTap) {
|
||||
return RichText(text: _buildInlineSpan(el, tokens, onRecordTap));
|
||||
}
|
||||
|
||||
static TextSpan _buildInlineSpan(md.Element el, SurfaceTokens tokens, {double? fontSize, FontWeight? fontWeight}) {
|
||||
static TextSpan _buildInlineSpan(md.Element el, SurfaceTokens tokens, RecordTapCallback? onRecordTap, {double? fontSize, FontWeight? fontWeight}) {
|
||||
final children = <InlineSpan>[];
|
||||
for (final child in el.children ?? const []) {
|
||||
if (child is md.Text) {
|
||||
children.add(TextSpan(text: child.text));
|
||||
children.add(TextSpan(text: _unescapeHtml(child.text)));
|
||||
} else if (child is md.Element) {
|
||||
children.add(_inlineElementSpan(child, tokens));
|
||||
children.add(_inlineElementSpan(child, tokens, onRecordTap));
|
||||
}
|
||||
}
|
||||
return TextSpan(
|
||||
style: TextStyle(
|
||||
fontFamily: clideUiFamily,
|
||||
fontFamilyFallback: clideUiFamilyFallback,
|
||||
fontWeight: fontWeight ?? clideUiDefaultWeight,
|
||||
color: tokens.globalForeground,
|
||||
fontSize: fontSize ?? clideFontBody,
|
||||
fontWeight: fontWeight,
|
||||
fontSize: fontSize ?? _fontSize,
|
||||
height: _lineHeight,
|
||||
),
|
||||
children: children,
|
||||
);
|
||||
}
|
||||
|
||||
static TextSpan _inlineElementSpan(md.Element el, SurfaceTokens tokens) {
|
||||
static InlineSpan _inlineElementSpan(md.Element el, SurfaceTokens tokens, RecordTapCallback? onRecordTap) {
|
||||
switch (el.tag) {
|
||||
case 'strong':
|
||||
return TextSpan(
|
||||
style: const TextStyle(fontWeight: FontWeight.w700),
|
||||
children: [for (final c in el.children ?? const []) if (c is md.Text) TextSpan(text: c.text) else if (c is md.Element) _inlineElementSpan(c, tokens)],
|
||||
children: [for (final c in el.children ?? const []) if (c is md.Text) TextSpan(text: _unescapeHtml(c.text)) else if (c is md.Element) _inlineElementSpan(c, tokens, onRecordTap)],
|
||||
);
|
||||
case 'em':
|
||||
return TextSpan(
|
||||
style: const TextStyle(fontStyle: FontStyle.italic),
|
||||
children: [for (final c in el.children ?? const []) if (c is md.Text) TextSpan(text: c.text) else if (c is md.Element) _inlineElementSpan(c, tokens)],
|
||||
children: [for (final c in el.children ?? const []) if (c is md.Text) TextSpan(text: _unescapeHtml(c.text)) else if (c is md.Element) _inlineElementSpan(c, tokens, onRecordTap)],
|
||||
);
|
||||
case 'code':
|
||||
return TextSpan(
|
||||
text: el.textContent,
|
||||
text: _unescapeHtml(el.textContent),
|
||||
style: TextStyle(fontFamily: clideMonoFamily, fontSize: clideFontMono, color: tokens.syntaxString, backgroundColor: tokens.panelBackground),
|
||||
);
|
||||
case 'a':
|
||||
final text = _unescapeHtml(el.textContent);
|
||||
if (onRecordTap != null && _recordPattern.hasMatch(text)) {
|
||||
return WidgetSpan(
|
||||
alignment: PlaceholderAlignment.baseline,
|
||||
baseline: TextBaseline.alphabetic,
|
||||
child: ClideTappable(
|
||||
onTap: () => onRecordTap(text),
|
||||
builder: (_, hovered, __) => Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
color: tokens.globalFocus,
|
||||
fontSize: _fontSize,
|
||||
height: _lineHeight,
|
||||
fontFamily: clideMonoFamily,
|
||||
decoration: hovered ? TextDecoration.underline : null,
|
||||
decorationColor: tokens.globalFocus,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return TextSpan(
|
||||
text: el.textContent,
|
||||
text: text,
|
||||
style: TextStyle(color: tokens.globalFocus),
|
||||
);
|
||||
case 'del':
|
||||
return TextSpan(
|
||||
text: el.textContent,
|
||||
text: _unescapeHtml(el.textContent),
|
||||
style: TextStyle(decoration: TextDecoration.lineThrough, color: tokens.globalTextMuted),
|
||||
);
|
||||
default:
|
||||
return TextSpan(text: el.textContent);
|
||||
return TextSpan(text: _unescapeHtml(el.textContent));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +56,9 @@ const String clideMonoFamily = 'JetBrainsMono';
|
||||
const double clideFontBody = 15;
|
||||
const double clideFontCaption = 14;
|
||||
const double clideFontMono = 14;
|
||||
const double clideFontSmall = 12;
|
||||
const double clideFontBadge = 11;
|
||||
const double clideLineHeight = 1.25;
|
||||
|
||||
/// System fallback chain. Ordered by platform prevalence + quality of
|
||||
/// programming-ligature / box-drawing coverage.
|
||||
|
||||
Reference in New Issue
Block a user