diff --git a/lib/pages/comments_page.dart b/lib/pages/comments_page.dart index cc345e7..8f19673 100644 --- a/lib/pages/comments_page.dart +++ b/lib/pages/comments_page.dart @@ -1,8 +1,14 @@ +import 'dart:collection'; + +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; +import 'package:url_launcher/url_launcher_string.dart'; import 'package:venera/components/components.dart'; import 'package:venera/foundation/app.dart'; import 'package:venera/foundation/comic_source/comic_source.dart'; import 'package:venera/foundation/image_provider/cached_image.dart'; +import 'package:venera/utils/app_links.dart'; +import 'package:venera/utils/ext.dart'; import 'package:venera/utils/translations.dart'; class CommentsPage extends StatefulWidget { @@ -268,7 +274,10 @@ class _CommentTileState extends State<_CommentTile> { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(widget.comment.userName, style: ts.bold,), + Text( + widget.comment.userName, + style: ts.bold, + ), if (widget.comment.time != null) Text(widget.comment.time!, style: ts.s12), const SizedBox(height: 4), @@ -426,7 +435,7 @@ class _CommentTileState extends State<_CommentTile> { isCancel, ); if (res.success) { - if(isCancel) { + if (isCancel) { voteStatus = 0; } else { if (isUp) { @@ -498,6 +507,287 @@ class _CommentContent extends StatelessWidget { @override Widget build(BuildContext context) { - return SelectableText(text); + if (!text.contains('<') && !text.contains('http')) { + return SelectableText(text); + } else { + return _RichCommentContent(text: text); + } + } +} + +class _Tag { + final String name; + final Map attributes; + + const _Tag(this.name, this.attributes); + + TextSpan merge(TextSpan s, BuildContext context) { + var style = s.style ?? ts; + style = switch (name) { + 'b' => style.bold, + 'i' => style.italic, + 'u' => style.underline, + 's' => style.lineThrough, + 'a' => style.withColor(context.colorScheme.primary), + 'span' => () { + if (attributes.containsKey('style')) { + var s = attributes['style']!; + var css = s.split(';'); + for (var c in css) { + var kv = c.split(':'); + if (kv.length == 2) { + var key = kv[0].trim(); + var value = kv[1].trim(); + switch (key) { + case 'color': + // Color is not supported, we should make text display well in light and dark mode. + break; + case 'font-weight': + if (value == 'bold') { + style = style.bold; + } else if (value == 'lighter') { + style = style.light; + } + break; + case 'font-style': + if (value == 'italic') { + style = style.italic; + } + break; + case 'text-decoration': + if (value == 'underline') { + style = style.underline; + } else if (value == 'line-through') { + style = style.lineThrough; + } + break; + case 'font-size': + // Font size is not supported. + break; + } + } + } + } + return style; + }(), + _ => style, + }; + if (style.color != null) { + style = style.copyWith(decorationColor: style.color); + } + var recognizer = s.recognizer; + if (name == 'a') { + var link = attributes['href']; + if (link != null && link.isURL) { + recognizer = TapGestureRecognizer() + ..onTap = () { + handleLink(link); + }; + } + } + return TextSpan( + text: s.text, + style: style, + recognizer: recognizer, + ); + } + + static void handleLink(String link) async { + if (link.isURL) { + if (await handleAppLink(Uri.parse(link))) { + App.rootContext.pop(); + } else { + launchUrlString(link); + } + } + } +} + +class _CommentImage { + final String url; + final String? link; + + const _CommentImage(this.url, this.link); +} + +class _RichCommentContent extends StatefulWidget { + const _RichCommentContent({required this.text}); + + final String text; + + @override + State<_RichCommentContent> createState() => _RichCommentContentState(); +} + +class _RichCommentContentState extends State<_RichCommentContent> { + var textSpan = []; + var images = <_CommentImage>[]; + + @override + void didChangeDependencies() { + render(); + super.didChangeDependencies(); + } + + bool isValidUrlChar(String char) { + return RegExp(r'[a-zA-Z0-9%:/.@\-_?&=#*!]').hasMatch(char); + } + + void render() { + var s = Queue<_Tag>(); + + int i = 0; + var buffer = StringBuffer(); + var text = widget.text; + + void writeBuffer() { + if (buffer.isEmpty) return; + var span = TextSpan(text: buffer.toString()); + for (var tag in s) { + span = tag.merge(span, context); + } + textSpan.add(span); + buffer.clear(); + } + + while (i < text.length) { + if (text[i] == '<' && i != text.length - 1) { + if (text[i + 1] != '/') { + // start tag + var j = text.indexOf('>', i); + if (j != -1) { + var tagContent = text.substring(i + 1, j); + var splits = tagContent.split(' '); + splits.removeWhere((element) => element.isEmpty); + var tagName = splits[0]; + var attributes = {}; + for (var k = 1; k < splits.length; k++) { + var attr = splits[k]; + var attrSplits = attr.split('='); + if (attrSplits.length == 2) { + attributes[attrSplits[0]] = attrSplits[1].replaceAll('"', ''); + } + } + const acceptedTags = ['img', 'a', 'b', 'i', 'u', 's', 'br', 'span']; + if (acceptedTags.contains(tagName)) { + writeBuffer(); + if (tagName == 'img') { + var url = attributes['src']; + String? link; + for (var tag in s) { + if (tag.name == 'a') { + link = tag.attributes['href']; + break; + } + } + if (url != null) { + images.add(_CommentImage(url, link)); + } + } else if (tagName == 'br') { + buffer.write('\n'); + } else { + s.add(_Tag(tagName, attributes)); + } + i = j + 1; + continue; + } + } + } else { + // end tag + var j = text.indexOf('>', i); + if (j != -1) { + var tagContent = text.substring(i + 2, j); + var splits = tagContent.split(' '); + splits.removeWhere((element) => element.isEmpty); + var tagName = splits[0]; + if (s.isNotEmpty && s.last.name == tagName) { + writeBuffer(); + s.removeLast(); + i = j + 1; + continue; + } + if (tagName == 'br') { + i = j + 1; + buffer.write('\n'); + continue; + } + } + } + } else if (text.length - i > 8 && + text.substring(i, i + 4) == 'http' && + !s.any((e) => e.name == 'a')) { + // auto link + int j = i; + for (; j < text.length; j++) { + if (!isValidUrlChar(text[j])) { + break; + } + } + var url = text.substring(i, j); + if (url.isURL) { + writeBuffer(); + textSpan.add(TextSpan( + text: url, + style: ts.withColor(context.colorScheme.primary), + recognizer: TapGestureRecognizer() + ..onTap = () { + _Tag.handleLink(url); + }, + )); + i = j; + continue; + } + } + buffer.write(text[i]); + i++; + } + writeBuffer(); + } + + @override + Widget build(BuildContext context) { + Widget content = SelectableText.rich( + TextSpan( + style: DefaultTextStyle.of(context).style, + children: textSpan, + ), + ); + if (images.isNotEmpty) { + content = Column( + mainAxisSize: MainAxisSize.min, + children: [ + content, + Wrap( + runSpacing: 4, + spacing: 4, + children: images.map((e) { + Widget image = Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: Theme.of(context).colorScheme.surfaceContainerLow, + ), + width: 100, + height: 100, + child: Image( + width: 100, + height: 100, + image: CachedImageProvider(e.url), + ), + ); + if (e.link != null) { + image = InkWell( + onTap: () { + _Tag.handleLink(e.link!); + }, + child: image, + ); + } + return image; + }).toList(), + ) + ], + ); + } + return content; } } diff --git a/lib/utils/app_links.dart b/lib/utils/app_links.dart index 2e36be3..e6947c7 100644 --- a/lib/utils/app_links.dart +++ b/lib/utils/app_links.dart @@ -10,7 +10,7 @@ void handleLinks() { }); } -void handleAppLink(Uri uri) async { +Future handleAppLink(Uri uri) async { for(var source in ComicSource.all()) { if(source.linkHandler != null) { if(source.linkHandler!.domains.contains(uri.host)) { @@ -22,9 +22,11 @@ void handleAppLink(Uri uri) async { App.mainNavigatorKey!.currentContext?.to(() { return ComicPage(id: id, sourceKey: source.key); }); + return true; } - return; + return false; } } } + return false; } \ No newline at end of file