diff --git a/lib/widgets/src/clide_markdown.dart b/lib/widgets/src/clide_markdown.dart index 97c4e94d..23dfce20 100644 --- a/lib/widgets/src/clide_markdown.dart +++ b/lib/widgets/src/clide_markdown.dart @@ -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 _buildNodes(List 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 _buildNodes(List nodes, SurfaceTokens tokens, RecordTapCallback? onRecordTap) { final out = []; + final inlineRun = []; + + void flushInline() { + if (inlineRun.isEmpty) return; + final spans = []; + 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() ?? const [], tokens), + children: _buildNodes(el.children?.cast() ?? const [], tokens, onRecordTap), ), ); case 'pre': final codeEl = el.children?.whereType().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() ?? const [], tokens), + children: _buildNodes(el.children?.cast() ?? 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 = []; 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 = []; 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)); } } } diff --git a/lib/widgets/src/typography.dart b/lib/widgets/src/typography.dart index d010aa55..7d21a148 100644 --- a/lib/widgets/src/typography.dart +++ b/lib/widgets/src/typography.dart @@ -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.