This commit is contained in:
parent
ecf000e466
commit
7bc9b5d709
|
|
@ -1115,191 +1115,201 @@ mixin _UpgradeDialogLogic<T extends StatefulWidget> on State<T> {
|
||||||
/// 递归解析富文本,支持嵌套格式
|
/// 递归解析富文本,支持嵌套格式
|
||||||
///
|
///
|
||||||
/// 支持的格式:
|
/// 支持的格式:
|
||||||
/// - `**粗体**` - 加粗文本
|
/// - `**粗体**`
|
||||||
/// - `__斜体__` - 斜体文本
|
/// - `__斜体__`
|
||||||
/// - `` `代码` `` - 等宽字体代码
|
/// - `` `代码` ``
|
||||||
/// - `[高亮]` - 主题色高亮
|
/// - `[高亮]`
|
||||||
///
|
///
|
||||||
/// 支持嵌套,例如:
|
/// 采用自定义解析器,逐字符扫描并使用递归解析子内容,从而支持任意嵌套。
|
||||||
/// - `__在[学生管理]中添加详情页__` - 斜体中嵌套高亮
|
|
||||||
/// - `[高亮**粗体**文本]` - 高亮中嵌套粗体
|
|
||||||
/// - `` `代码**粗体**` `` - 代码中嵌套粗体
|
|
||||||
///
|
|
||||||
/// 处理逻辑:
|
|
||||||
/// 1. 找到所有格式标记
|
|
||||||
/// 2. 识别嵌套关系(被其他格式完全包围的为内层)
|
|
||||||
/// 3. 优先处理最外层格式,递归处理内层内容
|
|
||||||
/// 4. 继续处理剩余文本
|
|
||||||
List<TextSpan> _parseRichText(String content, ColorScheme colorScheme) {
|
List<TextSpan> _parseRichText(String content, ColorScheme colorScheme) {
|
||||||
|
final result = _parseRichTextInternal(content, colorScheme, 0, null);
|
||||||
|
return result.spans;
|
||||||
|
}
|
||||||
|
|
||||||
|
_RichTextParseResult _parseRichTextInternal(
|
||||||
|
String text,
|
||||||
|
ColorScheme colorScheme,
|
||||||
|
int startIndex,
|
||||||
|
String? endToken,
|
||||||
|
) {
|
||||||
final spans = <TextSpan>[];
|
final spans = <TextSpan>[];
|
||||||
|
final buffer = StringBuffer();
|
||||||
|
var index = startIndex;
|
||||||
|
|
||||||
// 定义格式标记:使用严格的正则表达式,避免匹配不完整的标记
|
void flushBuffer() {
|
||||||
// 优先级:[] > ` > ** > __ (内层格式优先)
|
if (buffer.isEmpty) return;
|
||||||
final patterns = [
|
spans.add(TextSpan(
|
||||||
(regex: RegExp(r'\[([^\]]+)\]'), type: 'highlight'), // [高亮] - 不包含 ] 的内容
|
text: buffer.toString(),
|
||||||
(regex: RegExp(r'`([^`]+)`'), type: 'code'), // `代码` - 不包含 ` 的内容
|
style: _defaultRichTextStyle(colorScheme),
|
||||||
(regex: RegExp(r'\*\*([^*]+(?:\*(?!\*)[^*]*)*)\*\*'), type: 'bold'), // **粗体** - 不包含 ** 的内容
|
));
|
||||||
(regex: RegExp(r'__([^_]+(?:_(?!_)[^_]*)*)__'), type: 'italic'), // __斜体__ - 不包含 __ 的内容
|
buffer.clear();
|
||||||
];
|
}
|
||||||
|
|
||||||
// 找到所有匹配的格式标记
|
while (index < text.length) {
|
||||||
final allMatches = <({Match match, String type, int priority})>[];
|
// 如果遇到结束标记,返回到上层
|
||||||
for (int i = 0; i < patterns.length; i++) {
|
if (endToken != null && text.startsWith(endToken, index)) {
|
||||||
final pattern = patterns[i];
|
flushBuffer();
|
||||||
for (final match in pattern.regex.allMatches(content)) {
|
return _RichTextParseResult(spans, index + endToken.length, true);
|
||||||
// 确保匹配的内容不为空
|
}
|
||||||
if (match.group(1) != null && match.group(1)!.isNotEmpty) {
|
|
||||||
allMatches.add((match: match, type: pattern.type, priority: i));
|
final currentChar = text[index];
|
||||||
|
var handled = false;
|
||||||
|
|
||||||
|
// [高亮]
|
||||||
|
if (currentChar == '[') {
|
||||||
|
final innerResult = _parseRichTextInternal(text, colorScheme, index + 1, ']');
|
||||||
|
if (innerResult.closed) {
|
||||||
|
flushBuffer();
|
||||||
|
final innerText = text.substring(index + 1, innerResult.nextIndex - 1);
|
||||||
|
spans.addAll(_applyStyleToSpans(
|
||||||
|
innerResult.spans,
|
||||||
|
_highlightTextStyle(colorScheme),
|
||||||
|
innerText,
|
||||||
|
));
|
||||||
|
index = innerResult.nextIndex;
|
||||||
|
handled = true;
|
||||||
|
} else {
|
||||||
|
// 无法找到匹配的 ],按普通文本处理
|
||||||
|
buffer.write(currentChar);
|
||||||
|
index++;
|
||||||
|
handled = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// `代码`
|
||||||
|
else if (currentChar == '`') {
|
||||||
|
final closingIndex = text.indexOf('`', index + 1);
|
||||||
|
if (closingIndex != -1) {
|
||||||
|
flushBuffer();
|
||||||
|
final codeText = text.substring(index + 1, closingIndex);
|
||||||
|
spans.add(TextSpan(
|
||||||
|
text: codeText,
|
||||||
|
style: _codeTextStyle(colorScheme),
|
||||||
|
));
|
||||||
|
index = closingIndex + 1;
|
||||||
|
handled = true;
|
||||||
|
} else {
|
||||||
|
// 没有找到闭合的 `,按普通文本处理
|
||||||
|
buffer.write(currentChar);
|
||||||
|
index++;
|
||||||
|
handled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// **粗体**
|
||||||
|
else if (text.startsWith('**', index)) {
|
||||||
|
final innerResult = _parseRichTextInternal(text, colorScheme, index + 2, '**');
|
||||||
|
if (innerResult.closed) {
|
||||||
|
flushBuffer();
|
||||||
|
final innerText = text.substring(index + 2, innerResult.nextIndex - 2);
|
||||||
|
spans.addAll(_applyStyleToSpans(
|
||||||
|
innerResult.spans,
|
||||||
|
_boldTextStyle(colorScheme),
|
||||||
|
innerText,
|
||||||
|
));
|
||||||
|
index = innerResult.nextIndex;
|
||||||
|
handled = true;
|
||||||
|
} else {
|
||||||
|
// 没有匹配的 **
|
||||||
|
buffer.write('**');
|
||||||
|
index += 2;
|
||||||
|
handled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// __斜体__
|
||||||
|
else if (text.startsWith('__', index)) {
|
||||||
|
final innerResult = _parseRichTextInternal(text, colorScheme, index + 2, '__');
|
||||||
|
if (innerResult.closed) {
|
||||||
|
flushBuffer();
|
||||||
|
final innerText = text.substring(index + 2, innerResult.nextIndex - 2);
|
||||||
|
spans.addAll(_applyStyleToSpans(
|
||||||
|
innerResult.spans,
|
||||||
|
_italicTextStyle(colorScheme),
|
||||||
|
innerText,
|
||||||
|
));
|
||||||
|
index = innerResult.nextIndex;
|
||||||
|
handled = true;
|
||||||
|
} else {
|
||||||
|
buffer.write('__');
|
||||||
|
index += 2;
|
||||||
|
handled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!handled) {
|
||||||
|
buffer.write(currentChar);
|
||||||
|
index++;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果没有匹配,返回普通文本
|
flushBuffer();
|
||||||
if (allMatches.isEmpty) {
|
return _RichTextParseResult(spans, index, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
TextStyle _defaultRichTextStyle(ColorScheme colorScheme) {
|
||||||
|
return TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
color: colorScheme.onSurface.withOpacity(0.8),
|
||||||
|
height: 1.4,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
TextStyle _boldTextStyle(ColorScheme colorScheme) {
|
||||||
|
return TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
color: colorScheme.onSurface.withOpacity(0.9),
|
||||||
|
height: 1.4,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
TextStyle _italicTextStyle(ColorScheme colorScheme) {
|
||||||
|
return TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
color: colorScheme.onSurface.withOpacity(0.9),
|
||||||
|
height: 1.4,
|
||||||
|
fontStyle: FontStyle.italic,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
TextStyle _codeTextStyle(ColorScheme colorScheme) {
|
||||||
|
return TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: colorScheme.primary,
|
||||||
|
height: 1.4,
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
backgroundColor: colorScheme.primaryContainer.withOpacity(0.2),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
TextStyle _highlightTextStyle(ColorScheme colorScheme) {
|
||||||
|
return TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
color: colorScheme.primary,
|
||||||
|
height: 1.4,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<TextSpan> _applyStyleToSpans(List<TextSpan> spans, TextStyle style, String fallbackText) {
|
||||||
|
if (spans.isEmpty) {
|
||||||
return [
|
return [
|
||||||
TextSpan(
|
TextSpan(
|
||||||
text: content,
|
text: fallbackText,
|
||||||
style: TextStyle(
|
style: style,
|
||||||
fontSize: 13,
|
|
||||||
color: colorScheme.onSurface.withOpacity(0.8),
|
|
||||||
height: 1.4,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 按位置排序,找到最左边的匹配
|
return spans.map((span) => _mergeTextSpanStyle(span, style)).toList();
|
||||||
allMatches.sort((a, b) {
|
}
|
||||||
// 先按开始位置排序
|
|
||||||
if (a.match.start != b.match.start) {
|
|
||||||
return a.match.start.compareTo(b.match.start);
|
|
||||||
}
|
|
||||||
// 如果开始位置相同,按优先级排序(高优先级在前,即 [] 和 ` 优先)
|
|
||||||
if (a.priority != b.priority) {
|
|
||||||
return a.priority.compareTo(b.priority);
|
|
||||||
}
|
|
||||||
// 如果优先级也相同,按结束位置排序(短的在前)
|
|
||||||
return a.match.end.compareTo(b.match.end);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 找到第一个不被其他格式完全包围的匹配(最外层的)
|
TextSpan _mergeTextSpanStyle(TextSpan span, TextStyle style) {
|
||||||
Match? selectedMatch;
|
final mergedChildren =
|
||||||
String? selectedType;
|
span.children?.map((child) => child is TextSpan ? _mergeTextSpanStyle(child, style) : child).toList();
|
||||||
|
return TextSpan(
|
||||||
for (final item in allMatches) {
|
text: span.text,
|
||||||
bool isNested = false;
|
style: span.style?.merge(style) ?? style,
|
||||||
// 检查是否被其他格式完全包围(开始位置更早且结束位置更晚)
|
children: mergedChildren,
|
||||||
for (final other in allMatches) {
|
);
|
||||||
if (other.match != item.match &&
|
|
||||||
other.match.start <= item.match.start &&
|
|
||||||
other.match.end >= item.match.end &&
|
|
||||||
(other.match.start < item.match.start || other.match.end > item.match.end)) {
|
|
||||||
isNested = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 选择第一个不被嵌套的匹配
|
|
||||||
if (!isNested) {
|
|
||||||
selectedMatch = item.match;
|
|
||||||
selectedType = item.type;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果没找到(理论上不应该发生),使用第一个匹配
|
|
||||||
selectedMatch ??= allMatches.first.match;
|
|
||||||
selectedType ??= allMatches.first.type;
|
|
||||||
|
|
||||||
int lastIndex = 0;
|
|
||||||
|
|
||||||
// 添加匹配前的普通文本
|
|
||||||
if (selectedMatch.start > lastIndex) {
|
|
||||||
spans.add(TextSpan(
|
|
||||||
text: content.substring(lastIndex, selectedMatch.start),
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 13,
|
|
||||||
color: colorScheme.onSurface.withOpacity(0.8),
|
|
||||||
height: 1.4,
|
|
||||||
),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理匹配到的格式内容(递归解析,支持嵌套)
|
|
||||||
final matchedContent = selectedMatch.group(1)!;
|
|
||||||
final nestedSpans = _parseRichText(matchedContent, colorScheme);
|
|
||||||
|
|
||||||
// 应用当前格式的样式
|
|
||||||
TextStyle baseStyle;
|
|
||||||
switch (selectedType) {
|
|
||||||
case 'bold':
|
|
||||||
baseStyle = TextStyle(
|
|
||||||
fontSize: 13,
|
|
||||||
color: colorScheme.onSurface.withOpacity(0.9),
|
|
||||||
height: 1.4,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
case 'italic':
|
|
||||||
baseStyle = TextStyle(
|
|
||||||
fontSize: 13,
|
|
||||||
color: colorScheme.onSurface.withOpacity(0.9),
|
|
||||||
height: 1.4,
|
|
||||||
fontStyle: FontStyle.italic,
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
case 'code':
|
|
||||||
baseStyle = TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
color: colorScheme.primary,
|
|
||||||
height: 1.4,
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
backgroundColor: colorScheme.primaryContainer.withOpacity(0.2),
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
case 'highlight':
|
|
||||||
baseStyle = TextStyle(
|
|
||||||
fontSize: 13,
|
|
||||||
color: colorScheme.primary,
|
|
||||||
height: 1.4,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
baseStyle = TextStyle(
|
|
||||||
fontSize: 13,
|
|
||||||
color: colorScheme.onSurface.withOpacity(0.8),
|
|
||||||
height: 1.4,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果嵌套解析有结果,应用样式;否则直接使用匹配的内容
|
|
||||||
if (nestedSpans.isNotEmpty &&
|
|
||||||
(nestedSpans.length > 1 || (nestedSpans.isNotEmpty && nestedSpans[0].text != matchedContent))) {
|
|
||||||
// 有嵌套格式,应用样式到所有嵌套的spans
|
|
||||||
final styledSpans = nestedSpans.map((span) {
|
|
||||||
return TextSpan(
|
|
||||||
text: span.text,
|
|
||||||
style: span.style?.merge(baseStyle) ?? baseStyle,
|
|
||||||
children: span.children,
|
|
||||||
);
|
|
||||||
}).toList();
|
|
||||||
spans.addAll(styledSpans);
|
|
||||||
} else {
|
|
||||||
// 无嵌套格式,直接应用样式
|
|
||||||
spans.add(TextSpan(
|
|
||||||
text: matchedContent,
|
|
||||||
style: baseStyle,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 继续解析剩余部分
|
|
||||||
lastIndex = selectedMatch.end;
|
|
||||||
if (lastIndex < content.length) {
|
|
||||||
final remainingSpans = _parseRichText(content.substring(lastIndex), colorScheme);
|
|
||||||
spans.addAll(remainingSpans);
|
|
||||||
}
|
|
||||||
|
|
||||||
return spans;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildEnhancedDownloadProgress(BuildContext context, ColorScheme colorScheme) {
|
Widget _buildEnhancedDownloadProgress(BuildContext context, ColorScheme colorScheme) {
|
||||||
|
|
@ -2207,3 +2217,11 @@ class _ToastWidget extends StatelessWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
typedef BoolCallback = void Function(bool success);
|
typedef BoolCallback = void Function(bool success);
|
||||||
|
|
||||||
|
class _RichTextParseResult {
|
||||||
|
final List<TextSpan> spans;
|
||||||
|
final int nextIndex;
|
||||||
|
final bool closed;
|
||||||
|
|
||||||
|
const _RichTextParseResult(this.spans, this.nextIndex, this.closed);
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue