Files
Client/lib/widget/prosemirror/pm_rich_text.dart
T

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),
};