300 lines
8.8 KiB
Dart
300 lines
8.8 KiB
Dart
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);
|
||
}
|