209 lines
6.1 KiB
Dart
209 lines
6.1 KiB
Dart
import 'package:flutter/gestures.dart';
|
|
import 'package:flutter/material.dart';
|
|
|
|
import 'pm_node.dart';
|
|
import 'pm_render_scope.dart';
|
|
|
|
/// Renders a run of inline nodes ([PmText] / [PmHardBreak]) as a single
|
|
/// `Text.rich`, composing marks into `TextStyle`s.
|
|
///
|
|
/// Link marks need a [TapGestureRecognizer], which must be disposed to avoid
|
|
/// leaks. The recognizers are therefore built in [didChangeDependencies] /
|
|
/// [didUpdateWidget] and released in [dispose] — never allocated inside
|
|
/// [build].
|
|
class PmRichText extends StatefulWidget {
|
|
final List<PmNode> inlines;
|
|
final TextStyle baseStyle;
|
|
final TextAlign textAlign;
|
|
|
|
const PmRichText({
|
|
required this.inlines,
|
|
required this.baseStyle,
|
|
this.textAlign = TextAlign.start,
|
|
super.key,
|
|
});
|
|
|
|
@override
|
|
State<PmRichText> createState() => _PmRichTextState();
|
|
}
|
|
|
|
class _PmRichTextState extends State<PmRichText> {
|
|
final List<TapGestureRecognizer> _recognizers = [];
|
|
InlineSpan _span = const TextSpan();
|
|
|
|
@override
|
|
void didChangeDependencies() {
|
|
super.didChangeDependencies();
|
|
_rebuild();
|
|
}
|
|
|
|
@override
|
|
void didUpdateWidget(PmRichText oldWidget) {
|
|
super.didUpdateWidget(oldWidget);
|
|
if (oldWidget.inlines != widget.inlines ||
|
|
oldWidget.baseStyle != widget.baseStyle ||
|
|
oldWidget.textAlign != widget.textAlign) {
|
|
_rebuild();
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_disposeRecognizers();
|
|
super.dispose();
|
|
}
|
|
|
|
void _disposeRecognizers() {
|
|
for (final recognizer in _recognizers) {
|
|
recognizer.dispose();
|
|
}
|
|
_recognizers.clear();
|
|
}
|
|
|
|
void _rebuild() {
|
|
_disposeRecognizers();
|
|
final theme = Theme.of(context);
|
|
final onLinkTap = PmRenderScope.maybeOf(context)?.onLinkTap;
|
|
final spans = <InlineSpan>[];
|
|
for (final node in widget.inlines) {
|
|
if (node is PmHardBreak) {
|
|
spans.add(const TextSpan(text: '\n'));
|
|
} else if (node is PmText) {
|
|
spans.add(_textSpan(node, theme, onLinkTap));
|
|
}
|
|
}
|
|
_span = TextSpan(style: widget.baseStyle, children: spans);
|
|
}
|
|
|
|
TextSpan _textSpan(
|
|
PmText node,
|
|
ThemeData theme,
|
|
void Function(String href)? onLinkTap,
|
|
) {
|
|
String? href;
|
|
final style = _styleForMarks(
|
|
node.marks,
|
|
widget.baseStyle,
|
|
theme,
|
|
onLink: (value) => href = value,
|
|
);
|
|
TapGestureRecognizer? recognizer;
|
|
if (href != null && onLinkTap != null) {
|
|
final target = href!;
|
|
recognizer = TapGestureRecognizer()..onTap = () => onLinkTap(target);
|
|
_recognizers.add(recognizer);
|
|
}
|
|
return TextSpan(text: node.text, style: style, recognizer: recognizer);
|
|
}
|
|
|
|
TextStyle _styleForMarks(
|
|
List<PmMark> marks,
|
|
TextStyle base,
|
|
ThemeData theme, {
|
|
required void Function(String href) onLink,
|
|
}) {
|
|
var style = base;
|
|
final decorations = <TextDecoration>[];
|
|
for (final mark in marks) {
|
|
switch (mark.type) {
|
|
case 'bold':
|
|
style = style.copyWith(fontWeight: FontWeight.bold);
|
|
case 'italic':
|
|
style = style.copyWith(fontStyle: FontStyle.italic);
|
|
case 'strike':
|
|
decorations.add(TextDecoration.lineThrough);
|
|
case 'underline':
|
|
decorations.add(TextDecoration.underline);
|
|
case 'code':
|
|
style = style.copyWith(
|
|
fontFamily: 'monospace',
|
|
backgroundColor: theme.colorScheme.surfaceContainerHighest,
|
|
);
|
|
case 'highlight':
|
|
style = style.copyWith(
|
|
backgroundColor: _highlightColor(mark.attrs['color'], theme),
|
|
);
|
|
case 'textStyle':
|
|
final size = _fontSize(mark.attrs['fontSize'], base.fontSize ?? 14);
|
|
if (size != null) style = style.copyWith(fontSize: size);
|
|
case 'link':
|
|
final rawHref = mark.attrs['href'];
|
|
if (rawHref is String && rawHref.isNotEmpty) {
|
|
onLink(rawHref);
|
|
style = style.copyWith(color: theme.colorScheme.primary);
|
|
decorations.add(TextDecoration.underline);
|
|
}
|
|
}
|
|
}
|
|
if (decorations.isNotEmpty) {
|
|
style = style.copyWith(decoration: TextDecoration.combine(decorations));
|
|
}
|
|
return style;
|
|
}
|
|
|
|
Color _highlightColor(dynamic color, ThemeData theme) {
|
|
final parsed = color is String ? _parseCssColor(color) : null;
|
|
final base = parsed ?? theme.colorScheme.tertiaryContainer;
|
|
// Full-opacity highlights swamp the text in dark mode, so dial the alpha
|
|
// down further there while keeping the accent readable in light mode.
|
|
final dark = theme.brightness == Brightness.dark;
|
|
return base.withValues(alpha: dark ? 0.30 : 0.45);
|
|
}
|
|
|
|
double? _fontSize(dynamic raw, double base) {
|
|
if (raw is! String) return null;
|
|
final match = RegExp(r'^(\d+(?:\.\d+)?)(px|rem|em|%)$').firstMatch(raw);
|
|
if (match == null) return null;
|
|
final value = double.parse(match.group(1)!);
|
|
final double size;
|
|
switch (match.group(2)) {
|
|
case 'px':
|
|
size = value;
|
|
case 'rem':
|
|
size = value * 16;
|
|
case 'em':
|
|
size = base * value;
|
|
case '%':
|
|
size = base * value / 100;
|
|
default:
|
|
return null;
|
|
}
|
|
return size.clamp(8, 72);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) =>
|
|
Text.rich(_span, textAlign: widget.textAlign);
|
|
}
|
|
|
|
/// Parses a CSS `#rgb`/`#rrggbb`/`#rrggbbaa` hex or a small set of named colors.
|
|
/// Returns `null` for anything unrecognised so the caller can fall back.
|
|
Color? _parseCssColor(String raw) {
|
|
final value = raw.trim().toLowerCase();
|
|
if (value.startsWith('#')) {
|
|
var hex = value.substring(1);
|
|
if (hex.length == 3) {
|
|
hex = hex.split('').map((c) => '$c$c').join();
|
|
}
|
|
if (hex.length == 6) hex = 'ff$hex';
|
|
if (hex.length == 8) {
|
|
final rgba = int.tryParse(hex, radix: 16);
|
|
if (rgba != null) return Color(rgba);
|
|
}
|
|
return null;
|
|
}
|
|
return _namedColors[value];
|
|
}
|
|
|
|
const _namedColors = <String, Color>{
|
|
'red': Color(0xFFEF4444),
|
|
'orange': Color(0xFFF97316),
|
|
'yellow': Color(0xFFFACC15),
|
|
'green': Color(0xFF22C55E),
|
|
'blue': Color(0xFF3B82F6),
|
|
'purple': Color(0xFFA855F7),
|
|
'pink': Color(0xFFEC4899),
|
|
'gray': Color(0xFF9CA3AF),
|
|
'grey': Color(0xFF9CA3AF),
|
|
};
|