Files
jinzhi/lib/common/widgets/markdown_table.dart
T
2026-06-18 15:02:28 +08:00

300 lines
8.8 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<String> header;
final List<List<String>> rows;
final List<TextAlign> 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<double>.filled(n, 0);
void acc(List<String> 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<double>(0, (a, b) => a + b) + (n + 1) * _border;
List<double> widths;
bool scroll;
if (sum <= avail) {
// 够宽:把多余宽度按列固有宽比例分摊,填满整行
final extra = avail - sum;
final total = intrinsic.fold<double>(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<String> 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<String> _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<MdSegment> splitMarkdownTables(String md) {
final lines = md.split('\n');
final out = <MdSegment>[];
final buf = <String>[];
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<TextAlign>.filled(headerCells.length, TextAlign.left);
final rows = <List<String>>[];
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 = <InlineSpan>[];
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);
}