import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; import '../util/external_link.dart'; /// GFM 表格的「填满 / 滚动」渲染。 /// /// flutter_markdown_plus 的内置 `_buildTable()` 二选一:FlexColumnWidth 填满但 /// 折行;IntrinsicColumnWidth 不折行但横向滚动里无法填满。自定义 builder 又被 /// 包强制覆盖。故把表格从 markdown 抽出,用本组件接管: /// - 量出各列内容固有宽度; /// - 总宽 ≤ 可用宽 → 按比例放大列宽**填满整行**(不滚动、不折行); /// - 总宽 > 可用宽 → 用固有列宽 + 横向滚动查看(不折行)。 /// 单元格支持 `**加粗**` 与 `[文字](链接)`(点击走外部承接弹窗)。 class MarkdownTable extends StatelessWidget { const MarkdownTable({ super.key, required this.header, required this.rows, required this.aligns, }); final List header; final List> rows; final List aligns; static const double _hPad = 12; static const double _vPad = 8; static const double _border = 1; @override Widget build(BuildContext context) { final cs = ShadTheme.of(context).colorScheme; final n = header.length; // 阅读字号倍率(表格在 ReaderScaled 子树内):量宽必须用同一 textScaler, // 否则缩放后列宽测算与实际渲染不符。 final textScaler = MediaQuery.textScalerOf(context); final headStyle = TextStyle( fontWeight: FontWeight.w600, fontSize: 13, color: cs.foreground, ); final bodyStyle = TextStyle( fontSize: 13, height: 1.5, color: cs.foreground, fontFeatures: const [FontFeature.tabularFigures()], ); // 各列固有宽 = 该列最宽单元格(含表头) + 左右内距;按加粗量(偏宽)更稳妥 final intrinsic = List.filled(n, 0); void acc(List cells) { for (var c = 0; c < n && c < cells.length; c++) { final w = _measure( _stripMd(cells[c]), bodyStyle.copyWith(fontWeight: FontWeight.w700), textScaler, ); if (w > intrinsic[c]) intrinsic[c] = w; } } acc(header); for (final r in rows) { acc(r); } for (var c = 0; c < n; c++) { intrinsic[c] += _hPad * 2; } return LayoutBuilder( builder: (context, constraints) { final avail = constraints.maxWidth; final sum = intrinsic.fold(0, (a, b) => a + b) + (n + 1) * _border; List widths; bool scroll; if (sum <= avail) { // 够宽:把多余宽度按列固有宽比例分摊,填满整行 final extra = avail - sum; final total = intrinsic.fold(0, (a, b) => a + b); widths = [ for (final w in intrinsic) w + (total > 0 ? extra * (w / total) : extra / n), ]; scroll = false; } else { widths = intrinsic; scroll = true; } final table = Table( defaultColumnWidth: const IntrinsicColumnWidth(), columnWidths: { for (var c = 0; c < n; c++) c: FixedColumnWidth(widths[c]), }, border: TableBorder.all( color: cs.border, width: _border, borderRadius: BorderRadius.circular(8), ), children: [ _row(context, header, headStyle, cs, isHeader: true), for (final r in rows) _row(context, r, bodyStyle, cs), ], ); if (!scroll) return table; return SingleChildScrollView( scrollDirection: Axis.horizontal, child: ConstrainedBox( constraints: BoxConstraints(minWidth: avail), child: table, ), ); }, ); } TableRow _row( BuildContext context, List cells, TextStyle style, ShadColorScheme cs, { bool isHeader = false, }) { return TableRow( decoration: isHeader ? BoxDecoration(color: cs.muted) : null, children: [ for (var c = 0; c < header.length; c++) Padding( padding: const EdgeInsets.symmetric( horizontal: _hPad, vertical: _vPad, ), child: Align( alignment: _alignment(c), child: Text.rich( _inlineSpans( context, c < cells.length ? cells[c] : '', style, cs, ), textAlign: c < aligns.length ? aligns[c] : TextAlign.left, softWrap: false, overflow: TextOverflow.visible, ), ), ), ], ); } Alignment _alignment(int c) { final a = c < aligns.length ? aligns[c] : TextAlign.left; return switch (a) { TextAlign.center => Alignment.center, TextAlign.right => Alignment.centerRight, _ => Alignment.centerLeft, }; } static double _measure(String text, TextStyle style, TextScaler textScaler) { final tp = TextPainter( text: TextSpan(text: text, style: style), maxLines: 1, textDirection: TextDirection.ltr, textScaler: textScaler, )..layout(); return tp.width; } } // ── 解析:从 markdown 抽出表格块,其余按原文保留 ────────────────────────── class MdSegment { const MdSegment.text(this.text) : table = null; const MdSegment.table(this.table) : text = ''; final String text; final MarkdownTable? table; bool get isTable => table != null; } final RegExp _sepLine = RegExp(r'^\s*\|?[\s:|-]*-[\s:|-]*\|?\s*$'); bool _isRowLine(String l) => l.contains('|'); bool _isSepLine(String l) => l.contains('-') && l.contains('|') && _sepLine.hasMatch(l); List _splitCells(String line) { var t = line.trim(); if (t.startsWith('|')) t = t.substring(1); if (t.endsWith('|')) t = t.substring(0, t.length - 1); return t.split('|').map((s) => s.trim()).toList(); } /// 把 markdown 拆成「文本段」与「表格段」,顺序保留。 List splitMarkdownTables(String md) { final lines = md.split('\n'); final out = []; final buf = []; void flush() { if (buf.isNotEmpty) { out.add(MdSegment.text(buf.join('\n'))); buf.clear(); } } var i = 0; while (i < lines.length) { final isHeader = _isRowLine(lines[i]) && i + 1 < lines.length && _isSepLine(lines[i + 1]); if (!isHeader) { buf.add(lines[i]); i++; continue; } flush(); final headerCells = _splitCells(lines[i]); // demo:忽略 markdown 列对齐,表格内容统一左对齐 // (右对齐会让右列大片留白只显几个字)。 final aligns = List.filled(headerCells.length, TextAlign.left); final rows = >[]; var j = i + 2; while (j < lines.length && _isRowLine(lines[j]) && !_isSepLine(lines[j])) { final cs = _splitCells(lines[j]); rows.add([ for (var c = 0; c < headerCells.length; c++) c < cs.length ? cs[c] : '', ]); j++; } out.add( MdSegment.table( MarkdownTable(header: headerCells, rows: rows, aligns: aligns), ), ); i = j; } flush(); return out; } // ── 单元格内联:**加粗** 与 [文字](链接) ──────────────────────────────── final RegExp _inlineToken = RegExp(r'\*\*(.+?)\*\*|\[([^\]]+)\]\(([^)]+)\)'); String _stripMd(String s) => s .replaceAllMapped(_inlineToken, (m) => m.group(1) ?? m.group(2) ?? '') .replaceAll('*', ''); InlineSpan _inlineSpans( BuildContext context, String raw, TextStyle base, ShadColorScheme cs, ) { final spans = []; var last = 0; for (final m in _inlineToken.allMatches(raw)) { if (m.start > last) { spans.add(TextSpan(text: raw.substring(last, m.start), style: base)); } if (m.group(1) != null) { spans.add( TextSpan( text: m.group(1), style: base.copyWith(fontWeight: FontWeight.w700), ), ); } else { final text = m.group(2)!; final url = m.group(3)!; spans.add( TextSpan( text: text, style: base.copyWith( color: cs.accentForeground, decoration: TextDecoration.underline, decorationColor: cs.accentForeground, ), recognizer: TapGestureRecognizer() ..onTap = () => openExternalUrl(context, url, fallbackToast: '链接即将上线'), ), ); } last = m.end; } if (last < raw.length) { spans.add(TextSpan(text: raw.substring(last), style: base)); } return TextSpan(children: spans); }