Files
App/lib/widgets/content/markdown.dart
2025-10-13 00:16:57 +08:00

562 lines
18 KiB
Dart

import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:flutter/services.dart';
import 'package:flutter_highlight/themes/a11y-dark.dart';
import 'package:flutter_highlight/themes/a11y-light.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/file.dart';
import 'package:island/pods/config.dart';
import 'package:island/widgets/alert.dart';
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';
import 'image.dart';
class MarkdownTextContent extends HookConsumerWidget {
static const String stickerRegex = r':([-\w]*\+[-\w]*):';
final String content;
final bool isAutoWarp;
final TextScaler? textScaler;
final TextStyle? textStyle;
final TextStyle? linkStyle;
final EdgeInsets? linesMargin;
final bool isSelectable;
final List<SnCloudFile>? attachments;
const MarkdownTextContent({
super.key,
required this.content,
this.isAutoWarp = false,
this.textScaler,
this.textStyle,
this.linkStyle,
this.isSelectable = false,
this.linesMargin,
this.attachments,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final baseUrl = ref.watch(serverUrlProvider);
final doesEnlargeSticker = useMemoized(() {
// Check if content only contains one sticker by matching the sticker pattern
final stickerPattern = RegExp(stickerRegex);
final matches = stickerPattern.allMatches(content);
// Content should only contain one sticker and nothing else (except whitespace)
final contentWithoutStickers =
content.replaceAll(stickerPattern, '').trim();
return matches.length == 1 && contentWithoutStickers.isEmpty;
}, [content]);
final isDark = Theme.of(context).brightness == Brightness.dark;
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,
);
final highlightGenerator = HighlightGenerator(
highlightColor: Theme.of(context).colorScheme.primaryContainer,
);
final spoilerRevealed = useState(false);
final spoilerGenerator = SpoilerGenerator(
backgroundColor: Theme.of(context).colorScheme.tertiary,
foregroundColor: Theme.of(context).colorScheme.onTertiary,
outlineColor: Theme.of(context).colorScheme.outline,
revealed: spoilerRevealed.value,
onToggle: () => spoilerRevealed.value = !spoilerRevealed.value,
);
return MarkdownBlock(
data: content,
selectable: isSelectable,
config: config.copy(
configs: [
isDark
? PreConfig.darkConfig.copy(textStyle: textStyle)
: PreConfig().copy(textStyle: textStyle),
PConfig(
textStyle: textStyle ?? Theme.of(context).textTheme.bodyMedium!,
),
HrConfig(height: 1, color: Theme.of(context).dividerColor),
PreConfig(
theme: isDark ? a11yDarkTheme : a11yLightTheme,
textStyle: GoogleFonts.robotoMono(fontSize: 14),
styleNotMatched: GoogleFonts.robotoMono(fontSize: 14),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.all(Radius.circular(8.0)),
),
),
TableConfig(
wrapper:
(child) => SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: child,
),
),
LinkConfig(
style:
linkStyle ??
TextStyle(color: Theme.of(context).colorScheme.primary),
onTap: (href) {
final url = Uri.tryParse(href);
if (url != null) {
if (url.scheme == 'solian') {
final fullPath = ['/', url.host, url.path].join('');
context.push(fullPath);
return;
}
final whitelistDomains = ['solian.app', 'solsynth.dev'];
if (whitelistDomains.any(
(domain) =>
url.host == domain || url.host.endsWith('.$domain'),
)) {
launchUrl(url, mode: LaunchMode.externalApplication);
return;
}
showConfirmAlert(
'openLinkConfirmDescription'.tr(args: [url.toString()]),
'openLinkConfirm'.tr(),
).then((value) {
if (value) {
launchUrl(url, mode: LaunchMode.externalApplication);
}
});
} else {
showSnackBar(
'brokenLink'.tr(args: [href]),
action: SnackBarAction(
label: 'copyToClipboard'.tr(),
onPressed: () {
Clipboard.setData(ClipboardData(text: href));
clearSnackBar(context);
},
),
);
}
},
),
ImgConfig(
builder: (url, attributes) {
final uri = Uri.parse(url);
if (uri.scheme == 'solian') {
switch (uri.host) {
case 'files':
final file = attachments?.firstWhereOrNull(
(file) => file.id == uri.pathSegments[0],
);
if (file == null) {
return const SizedBox.shrink();
}
return ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainer,
borderRadius: const BorderRadius.all(
Radius.circular(8),
),
),
child: CloudFileWidget(
item: file,
fit: BoxFit.cover,
).clipRRect(all: 8),
),
);
case 'stickers':
final size = doesEnlargeSticker ? 96.0 : 24.0;
final stickerUri =
'$baseUrl/sphere/stickers/lookup/${uri.pathSegments[0]}/open';
return ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainer,
borderRadius: const BorderRadius.all(
Radius.circular(8),
),
),
child: UniversalImage(
uri: stickerUri,
width: size,
height: size,
fit: BoxFit.contain,
noCacheOptimization: true,
),
),
);
}
}
final content = ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: ConstrainedBox(
constraints: BoxConstraints(maxHeight: 360),
child: UniversalImage(
uri: uri.toString(),
fit: BoxFit.contain,
),
),
);
return content;
},
),
],
),
generator: MarkdownTextContent.buildGenerator(
isDark: isDark,
linesMargin: linesMargin,
generators: [mentionGenerator, highlightGenerator, spoilerGenerator],
),
);
}
static MarkdownGenerator buildGenerator({
bool isDark = false,
EdgeInsets? linesMargin,
List<dynamic> generators = const [],
}) {
return MarkdownGenerator(
generators: [latexGenerator, ...generators],
inlineSyntaxList: [
_MetionInlineSyntax(),
_HighlightInlineSyntax(),
_SpoilerInlineSyntax(),
_StickerInlineSyntax(),
LatexSyntax(isDark),
],
linesMargin: linesMargin ?? EdgeInsets.symmetric(vertical: 4),
);
}
}
class _MetionInlineSyntax extends markdown.InlineSyntax {
_MetionInlineSyntax() : super(r'@[-a-zA-Z0-9_./]+');
@override
bool onMatch(markdown.InlineParser parser, Match match) {
final alias = match[0]!;
final parts = alias.substring(1).split('/');
final typeShortcut = parts.length == 1 ? 'u' : parts.first;
final type = switch (typeShortcut) {
'u' => 'accounts',
'r' => 'realms',
'p' => 'publishers',
"c" => 'chat',
_ => '',
};
final element =
markdown.Element('mention-chip', [markdown.Text(alias)])
..attributes['alias'] = alias
..attributes['type'] = type
..attributes['id'] = parts.last;
parser.addNode(element);
return true;
}
}
class _StickerInlineSyntax extends markdown.InlineSyntax {
_StickerInlineSyntax() : super(MarkdownTextContent.stickerRegex);
@override
bool onMatch(markdown.InlineParser parser, Match match) {
final placeholder = match[1]!;
final image = markdown.Element.text('img', '')
..attributes['src'] = Uri.encodeFull('solian://stickers/$placeholder');
parser.addNode(image);
return true;
}
}
class _HighlightInlineSyntax extends markdown.InlineSyntax {
_HighlightInlineSyntax() : super(r'==([^=]+)==');
@override
bool onMatch(markdown.InlineParser parser, Match match) {
final text = match[1]!;
final element = markdown.Element('highlight', [markdown.Text(text)]);
parser.addNode(element);
return true;
}
}
class _SpoilerInlineSyntax extends markdown.InlineSyntax {
_SpoilerInlineSyntax() : super(r'=!([^!]+)!=');
@override
bool onMatch(markdown.InlineParser parser, Match match) {
final text = match[1]!;
final element = markdown.Element('spoiler', [markdown.Text(text)]);
parser.addNode(element);
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<String, String> attributes,
List<SpanNode> 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<String, String> 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),
borderRadius: BorderRadius.circular(32),
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,
),
),
],
),
),
),
);
}
}
class HighlightGenerator extends SpanNodeGeneratorWithTag {
HighlightGenerator({required Color highlightColor})
: super(
tag: 'highlight',
generator: (
markdown.Element element,
MarkdownConfig config,
WidgetVisitor visitor,
) {
return HighlightSpanNode(
text: element.textContent,
highlightColor: highlightColor,
);
},
);
}
class HighlightSpanNode extends SpanNode {
final String text;
final Color highlightColor;
HighlightSpanNode({required this.text, required this.highlightColor});
@override
InlineSpan build() {
return TextSpan(
text: text,
style: TextStyle(backgroundColor: highlightColor),
);
}
}
class SpoilerGenerator extends SpanNodeGeneratorWithTag {
SpoilerGenerator({
required Color backgroundColor,
required Color foregroundColor,
required Color outlineColor,
required bool revealed,
required VoidCallback onToggle,
}) : super(
tag: 'spoiler',
generator: (
markdown.Element element,
MarkdownConfig config,
WidgetVisitor visitor,
) {
return SpoilerSpanNode(
text: element.textContent,
backgroundColor: backgroundColor,
foregroundColor: foregroundColor,
outlineColor: outlineColor,
revealed: revealed,
onToggle: onToggle,
);
},
);
}
class SpoilerSpanNode extends SpanNode {
final String text;
final Color backgroundColor;
final Color foregroundColor;
final Color outlineColor;
final bool revealed;
final VoidCallback onToggle;
SpoilerSpanNode({
required this.text,
required this.backgroundColor,
required this.foregroundColor,
required this.outlineColor,
required this.revealed,
required this.onToggle,
});
@override
InlineSpan build() {
return WidgetSpan(
child: InkWell(
onTap: onToggle,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
decoration: BoxDecoration(
color: revealed ? Colors.transparent : backgroundColor,
border: revealed ? Border.all(color: outlineColor, width: 1) : null,
borderRadius: BorderRadius.circular(4),
),
child:
revealed
? Row(
spacing: 6,
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Symbols.visibility, size: 18).padding(top: 1),
Flexible(child: Text(text)),
],
)
: Row(
spacing: 6,
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Symbols.visibility_off,
color: foregroundColor,
size: 18,
),
Flexible(
child:
Text(
'spoiler',
style: TextStyle(color: foregroundColor),
).tr(),
),
],
),
),
),
);
}
}