feat:参照研听的基础工程
This commit is contained in:
@@ -0,0 +1,299 @@
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user