From f9d033542e49509aa4163e631c665a8cf84f7fae Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Sun, 12 Oct 2025 17:56:31 +0800 Subject: [PATCH] :sparkles: Mention tag in markdown --- lib/widgets/content/markdown.dart | 144 +++++++++++++++++++++++++++++- 1 file changed, 140 insertions(+), 4 deletions(-) diff --git a/lib/widgets/content/markdown.dart b/lib/widgets/content/markdown.dart index de84e8b2..b0a22afc 100644 --- a/lib/widgets/content/markdown.dart +++ b/lib/widgets/content/markdown.dart @@ -15,6 +15,7 @@ import 'package:island/widgets/content/cloud_files.dart'; import 'package:island/widgets/content/markdown_latex.dart'; import 'package:markdown/markdown.dart' as markdown; import 'package:markdown_widget/markdown_widget.dart'; +import 'package:material_symbols_icons/symbols.dart'; import 'package:styled_widget/styled_widget.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -62,6 +63,17 @@ class MarkdownTextContent extends HookConsumerWidget { final config = isDark ? MarkdownConfig.darkConfig : MarkdownConfig.defaultConfig; + final onMentionTap = useCallback((String type, String id) { + final fullPath = '/$type/$id'; + context.push(fullPath); + }, [context]); + + final mentionGenerator = MentionChipGenerator( + backgroundColor: Theme.of(context).colorScheme.secondary, + foregroundColor: Theme.of(context).colorScheme.onSecondary, + onTap: onMentionTap, + ); + return MarkdownBlock( data: content, selectable: isSelectable, @@ -202,6 +214,7 @@ class MarkdownTextContent extends HookConsumerWidget { generator: MarkdownTextContent.buildGenerator( isDark: isDark, linesMargin: linesMargin, + generators: [mentionGenerator], ), ); } @@ -209,9 +222,10 @@ class MarkdownTextContent extends HookConsumerWidget { static MarkdownGenerator buildGenerator({ bool isDark = false, EdgeInsets? linesMargin, + List generators = const [], }) { return MarkdownGenerator( - generators: [latexGenerator], + generators: [latexGenerator, ...generators], inlineSyntaxList: [ _MetionInlineSyntax(), _StickerInlineSyntax(), @@ -237,9 +251,12 @@ class _MetionInlineSyntax extends markdown.InlineSyntax { "c" => 'chat', _ => '', }; - final anchor = markdown.Element.text('a', alias) - ..attributes['href'] = Uri.encodeFull('solian://$type/${parts.last}'); - parser.addNode(anchor); + final element = + markdown.Element('mention-chip', [markdown.Text(alias)]) + ..attributes['alias'] = alias + ..attributes['type'] = type + ..attributes['id'] = parts.last; + parser.addNode(element); return true; } @@ -258,3 +275,122 @@ class _StickerInlineSyntax extends markdown.InlineSyntax { return true; } } + +class MentionSpanNodeGenerator { + final Color backgroundColor; + final Color foregroundColor; + final void Function(String type, String id) onTap; + + MentionSpanNodeGenerator({ + required this.backgroundColor, + required this.foregroundColor, + required this.onTap, + }); + + SpanNode? call( + String tag, + Map attributes, + List children, + ) { + if (tag == 'mention-chip') { + return MentionChipSpanNode( + attributes: attributes, + backgroundColor: backgroundColor, + foregroundColor: foregroundColor, + onTap: onTap, + ); + } + return null; + } +} + +class MentionChipGenerator extends SpanNodeGeneratorWithTag { + MentionChipGenerator({ + required Color backgroundColor, + required Color foregroundColor, + required void Function(String type, String id) onTap, + }) : super( + tag: 'mention-chip', + generator: ( + markdown.Element element, + MarkdownConfig config, + WidgetVisitor visitor, + ) { + return MentionChipSpanNode( + attributes: element.attributes, + backgroundColor: backgroundColor, + foregroundColor: foregroundColor, + onTap: onTap, + ); + }, + ); +} + +class MentionChipSpanNode extends SpanNode { + final Map attributes; + final Color backgroundColor; + final Color foregroundColor; + final void Function(String type, String id) onTap; + + MentionChipSpanNode({ + required this.attributes, + required this.backgroundColor, + required this.foregroundColor, + required this.onTap, + }); + + @override + InlineSpan build() { + final alias = attributes['alias'] ?? ''; + final type = attributes['type'] ?? ''; + final id = attributes['id'] ?? ''; + + final parts = alias.substring(1).split('/'); + + return WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: InkWell( + onTap: () => onTap(type, id), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: backgroundColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(32), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + spacing: 6, + children: [ + Container( + decoration: BoxDecoration( + color: backgroundColor.withOpacity(0.5), + borderRadius: const BorderRadius.all(Radius.circular(32)), + ), + child: Icon( + switch (parts.first.isEmpty ? 'u' : parts.first) { + 'c' => Symbols.forum_rounded, + 'r' => Symbols.group_rounded, + 'u' => Symbols.person_rounded, + 'p' => Symbols.edit_rounded, + _ => Symbols.person_rounded, + }, + size: 14, + color: foregroundColor, + fill: 1, + ).padding(all: 2), + ), + Text( + parts.last, + style: TextStyle( + color: backgroundColor, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ), + ); + } +}