feat:参照研听的基础工程

This commit is contained in:
jingyun
2026-06-18 15:02:28 +08:00
commit 364fc837b3
367 changed files with 16495 additions and 0 deletions
+299
View File
@@ -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);
}