Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add a visual indicator to link taps in markdown #1540

Draft
wants to merge 5 commits into
base: develop
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 112 additions & 13 deletions lib/shared/common_markdown_body.dart
Original file line number Diff line number Diff line change
@@ -1,3 1,6 @@
import 'dart:async';

import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';

import 'package:jovial_svg/jovial_svg.dart';
Expand All @@ -18,7 21,7 @@ import 'package:thunder/utils/markdown/extended_markdown.dart';

enum CustomMarkdownType { superscript, subscript }

class CommonMarkdownBody extends StatelessWidget {
class CommonMarkdownBody extends StatefulWidget {
/// The markdown content body
final String body;

Expand All @@ -39,6 42,14 @@ class CommonMarkdownBody extends StatelessWidget {
this.imageMaxWidth,
});

@override
State<CommonMarkdownBody> createState() => _CommonMarkdownBodyState();
}

class _CommonMarkdownBodyState extends State<CommonMarkdownBody> {
final Set<String> elementsBeingTapped = {};
ExtendedMarkdownBody? _extendedMarkdownBody;

@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
Expand Down Expand Up @@ -98,7 109,7 @@ class CommonMarkdownBody extends StatelessWidget {
horizontalRuleDecoration: BoxDecoration(
border: Border(top: BorderSide(width: theme.textTheme.bodyMedium!.fontSize!, color: Colors.transparent)),
),
textScaleFactor: MediaQuery.of(context).textScaleFactor * (isComment == true ? state.commentFontSizeScale.textScaleFactor : state.contentFontSizeScale.textScaleFactor),
textScaleFactor: MediaQuery.of(context).textScaleFactor * (widget.isComment == true ? state.commentFontSizeScale.textScaleFactor : state.contentFontSizeScale.textScaleFactor),
);

// Custom extension set
Expand All @@ -108,17 119,37 @@ class CommonMarkdownBody extends StatelessWidget {
List.from(customExtensionSet.inlineSyntaxes)..addAll([SuperscriptInlineSyntax(), SubscriptInlineSyntax()]),
);

return ExtendedMarkdownBody(
data: body,
return _extendedMarkdownBody = ExtendedMarkdownBody(
data: widget.body,
extensionSet: customExtensionSet,
inlineSyntaxes: [LemmyLinkSyntax(), SubscriptInlineSyntax(), SuperscriptInlineSyntax()],
builders: {
'spoiler': SpoilerElementBuilder(),
'sub': SubscriptElementBuilder(),
'sup': SuperscriptElementBuilder(),
'a': LinkElementBuilder(
isComment: widget.isComment,
onTapLink: (text, url) => handleLinkTap(context, state, text, url),
onLongPressLink: (text, url) => handleLinkLongPress(context, state, text, url),
elementsBeingTapped: elementsBeingTapped,
onElementBeingTapped: (element) {
elementsBeingTapped.add(element);
_extendedMarkdownBody?.forceParseMarkdown?.call();
setState(() {});
},
onElementStoppedBeingTapped: (element) {
// Add a tiny delay before removing the element.
// This allows the visual indication to be displayed, even if the user taps very quickly
Future.delayed(const Duration(milliseconds: 50), () {
elementsBeingTapped.remove(element);
_extendedMarkdownBody?.forceParseMarkdown?.call();
setState(() {});
});
},
),
},
imageBuilder: (uri, title, alt) {
if (hideContent) return Container();
if (widget.hideContent) return Container();

return FutureBuilder(
future: isImageUriSvg(uri),
Expand All @@ -132,19 163,19 @@ class CommonMarkdownBody extends StatelessWidget {
? ImagePreview(
url: uri.toString(),
isExpandable: true,
isComment: isComment,
isComment: widget.isComment,
showFullHeightImages: true,
maxWidth: imageMaxWidth,
maxWidth: widget.imageMaxWidth,
altText: alt,
)
: Container(
constraints: isComment == true
constraints: widget.isComment == true
? BoxConstraints(
maxHeight: MediaQuery.of(context).size.width * 0.55,
maxWidth: MediaQuery.of(context).size.width * 0.60,
)
: BoxConstraints(
maxWidth: imageMaxWidth ?? MediaQuery.of(context).size.width - 24,
maxWidth: widget.imageMaxWidth ?? MediaQuery.of(context).size.width - 24,
),
child: ScalableImageWidget.fromSISource(
fit: BoxFit.contain,
Expand All @@ -157,12 188,10 @@ class CommonMarkdownBody extends StatelessWidget {
},
);
},
onTapLink: (text, url, title) => handleLinkTap(context, state, text, url),
onLongPressLink: (text, url, title) => handleLinkLongPress(context, state, text, url),
styleSheet: hideContent
styleSheet: widget.hideContent
? spoilerMarkdownStyleSheet
: MarkdownStyleSheet.fromTheme(theme).copyWith(
textScaleFactor: MediaQuery.of(context).textScaleFactor * (isComment == true ? state.commentFontSizeScale.textScaleFactor : state.contentFontSizeScale.textScaleFactor),
textScaleFactor: MediaQuery.of(context).textScaleFactor * (widget.isComment == true ? state.commentFontSizeScale.textScaleFactor : state.contentFontSizeScale.textScaleFactor),
blockquoteDecoration: BoxDecoration(
color: getBackgroundColor(context),
border: Border(left: BorderSide(color: theme.colorScheme.primary.withOpacity(0.75), width: 4)),
Expand Down Expand Up @@ -520,3 549,73 @@ class SuperscriptSubscriptWidget extends StatelessWidget {
);
}
}

/// Creates a [MarkdownElementBuilder] that renders links with a visual touch indicator
class LinkElementBuilder extends MarkdownElementBuilder {
final bool? isComment;
final void Function(String text, String? href)? onTapLink;
final void Function(String text, String? href)? onLongPressLink;
final Set<String> elementsBeingTapped;
final void Function(String key) onElementBeingTapped;
final void Function(String key) onElementStoppedBeingTapped;

LinkElementBuilder({
required this.isComment,
required this.onTapLink,
required this.onLongPressLink,
required this.elementsBeingTapped,
required this.onElementBeingTapped,
required this.onElementStoppedBeingTapped,
});

@override
Widget? visitElementAfterWithContext(BuildContext context, md.Element element, TextStyle? preferredStyle, TextStyle? parentStyle) {
final ThunderState state = context.read<ThunderBloc>().state;
final String text = element.textContent;
final String? href = element.attributes['href'];

String key = element.attributes[elementKey] == null ? (element.attributes[elementKey] = UniqueKey().toString()) : element.attributes[elementKey]!;

Timer? timer;
final TapGestureRecognizer tapGestureRecognizer = TapGestureRecognizer();
tapGestureRecognizer
..onTapCancel = () {
onElementStoppedBeingTapped(key);
}
..onTapUp = (_) {
onElementStoppedBeingTapped(key);

if (timer != null && timer!.isActive) {
if (onTapLink != null) {
onTapLink!(text, href);
}
timer?.cancel();
}
}
..onTapDown = (TapDownDetails details) {
onElementBeingTapped(key);

timer = Timer(const Duration(milliseconds: 350), () {
onElementStoppedBeingTapped(key);

tapGestureRecognizer.resolve(GestureDisposition.accepted);

if (onLongPressLink != null) {
onLongPressLink!(text, href);
}
});
};

// This hierarchy must remain as Text.rich -> TextSpan -> <text>
return Text.rich(
TextSpan(
style: preferredStyle?.copyWith(
backgroundColor: elementsBeingTapped.contains(key) ? Colors.blue.withOpacity(0.15) : null,
),
text: element.textContent,
recognizer: tapGestureRecognizer,
),
textScaleFactor: MediaQuery.of(context).textScaleFactor * (isComment == true ? state.commentFontSizeScale.textScaleFactor : state.contentFontSizeScale.textScaleFactor),
);
}
}
102 changes: 35 additions & 67 deletions lib/utils/markdown/extended_markdown.dart
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 10,9 @@ import 'package:flutter_markdown/flutter_markdown.dart';

import 'package:markdown/markdown.dart' as md;

/// Used as a dictionary key to index into an Element's attributes and assign a unique key
const String elementKey = 'element_key';

/// A non-scrolling widget that parses and displays Markdown. This is modified from [MarkdownBody]
/// to allow it to extend from [ExtendedMarkdownWidget] rather than the original [MarkdownWidget].
///
Expand All @@ -21,7 24,7 @@ import 'package:markdown/markdown.dart' as md;
/// * [ExtendedMarkdownBody], which is the modified version of [MarkdownBody] to support additional functionality.
class ExtendedMarkdownBody extends ExtendedMarkdownWidget {
/// Creates a non-scrolling widget that parses and displays Markdown.
const ExtendedMarkdownBody({
ExtendedMarkdownBody({
super.key,
required super.data,
super.selectable,
Expand All @@ -43,14 46,15 @@ class ExtendedMarkdownBody extends ExtendedMarkdownWidget {
this.shrinkWrap = true,
super.fitContent = true,
super.softLineBreak,
super.onLongPressLink,
});

/// If [shrinkWrap] is `true`, [MarkdownBody] will take the minimum height
/// that wraps its content. Otherwise, [MarkdownBody] will expand to the
/// maximum allowed height.
final bool shrinkWrap;

void Function()? forceParseMarkdown;

@override
Widget build(BuildContext context, List<Widget>? children) {
if (children!.length == 1 && shrinkWrap) {
Expand All @@ -72,16 76,6 @@ class ExtendedMarkdownBody extends ExtendedMarkdownWidget {
/// * [MarkdownWidget], which is the original implementation of this widget.
/// * [ExtendedMarkdownBody], which uses this widget to allow the additional functionality.
abstract class ExtendedMarkdownWidget extends MarkdownWidget {
/// Called when the user long presses a link.
final MarkdownTapLinkCallback? onLongPressLink;

/// The duration threshold for a long press on a link. It defaults to 350ms which is typically shorter
/// than the default long press timeout of 500ms used by other widgets such as [InkWell].
///
/// Having a shorter timeout allows the long-press action gesture to win over other long-press gestures. This reduces the
/// potential for other gestures to be trigged at the same time.
final Duration longPressLinkTimeout;

const ExtendedMarkdownWidget({
super.key,
required super.data,
Expand All @@ -90,8 84,6 @@ abstract class ExtendedMarkdownWidget extends MarkdownWidget {
super.styleSheetTheme = MarkdownStyleSheetBaseTheme.material,
super.syntaxHighlighter,
super.onTapLink,
this.onLongPressLink,
this.longPressLinkTimeout = const Duration(milliseconds: 350),
super.onTapText,
super.imageDirectory,
super.blockSyntaxes,
Expand All @@ -112,11 104,8 @@ abstract class ExtendedMarkdownWidget extends MarkdownWidget {
}

class _MarkdownWidgetState extends State<ExtendedMarkdownWidget> implements MarkdownBuilderDelegate {
Timer? _timer;
late TapGestureRecognizer _tapGestureRecognizer;

List<Widget>? _children;
final List<GestureRecognizer> _recognizers = <GestureRecognizer>[];
List<md.Node>? _astNodes;

@override
void didChangeDependencies() {
Expand All @@ -132,22 121,10 @@ class _MarkdownWidgetState extends State<ExtendedMarkdownWidget> implements Mark
}
}

@override
void dispose() {
if (_timer != null) {
_timer?.cancel();
_timer = null;
}
_disposeRecognizers();
super.dispose();
}

void _parseMarkdown() {
final MarkdownStyleSheet fallbackStyleSheet = kFallbackStyle(context, widget.styleSheetTheme);
final MarkdownStyleSheet styleSheet = fallbackStyleSheet.merge(widget.styleSheet);

_disposeRecognizers();

final md.Document document = md.Document(
blockSyntaxes: widget.blockSyntaxes,
inlineSyntaxes: widget.inlineSyntaxes,
Expand Down Expand Up @@ -177,50 154,31 @@ class _MarkdownWidgetState extends State<ExtendedMarkdownWidget> implements Mark
softLineBreak: widget.softLineBreak,
);

_children = builder.build(astNodes);
}
// Recursively apply any custom attributes from the previously built set of ast nodes to the new one
_applyCustomAttributes(_astNodes, astNodes, elementKey);

void _disposeRecognizers() {
if (_recognizers.isEmpty) {
return;
}
final List<GestureRecognizer> localRecognizers = List<GestureRecognizer>.from(_recognizers);
_recognizers.clear();
for (final GestureRecognizer recognizer in localRecognizers) {
recognizer.dispose();
}
_children = builder.build(_astNodes = astNodes);
}

/// Modified function that allows for tap and long press detection on links.
/// The long press detection is determined by a Timer with a given timeout of [longPressLinkTimeout].
///
/// When tapped, the [onTapLink] callback is called. Similarly, when long pressed, the [onLongPressLink] callback is called.
/// To see the original implementation of this function, see [MarkdownWidget].
@override
GestureRecognizer createLink(String text, String? href, String title) {
_tapGestureRecognizer = TapGestureRecognizer();
void _applyCustomAttributes(List<md.Node>? previousAstNodes, List<md.Node>? newAstNodes, String customAttribute) {
if (previousAstNodes == null || newAstNodes == null) return;

_tapGestureRecognizer.onTapUp = (_) {
if (_timer != null && _timer!.isActive) {
if (widget.onTapLink != null) {
widget.onTapLink!(text, href, title);
}
_timer?.cancel();
}
};
int minLength = previousAstNodes.length < newAstNodes.length ? previousAstNodes.length : newAstNodes.length;

_tapGestureRecognizer.onTapDown = (TapDownDetails details) {
_timer = Timer(widget.longPressLinkTimeout, () {
_tapGestureRecognizer.resolve(GestureDisposition.accepted);
for (int i = 0; i < minLength; i ) {
if (previousAstNodes[i] is md.Element && newAstNodes[i] is md.Element) {
md.Element oldNode = previousAstNodes[i] as md.Element;
md.Element newNode = newAstNodes[i] as md.Element;

if (widget.onLongPressLink != null) {
widget.onLongPressLink!(text, href, title);
if (oldNode.attributes[customAttribute] != null) {
newNode.attributes[customAttribute] = oldNode.attributes[customAttribute]!;
}
});
};

_recognizers.add(_tapGestureRecognizer);
return _tapGestureRecognizer;
if (oldNode.children?.isNotEmpty == true && newNode.children?.isNotEmpty == true) {
_applyCustomAttributes(oldNode.children!, newNode.children!, customAttribute);
}
}
}
}

@override
Expand All @@ -233,7 191,17 @@ class _MarkdownWidgetState extends State<ExtendedMarkdownWidget> implements Mark
}

@override
Widget build(BuildContext context) => widget.build(context, _children);
Widget build(BuildContext context) {
(widget as ExtendedMarkdownBody?)?.forceParseMarkdown = () => _parseMarkdown();
return widget.build(context, _children);
}

@override
GestureRecognizer createLink(String text, String? href, String title) {
// Note: We need this override to satisfy the base class,
// but this gesture recognizer is not actually used for links since we have a custom builder.
return TapGestureRecognizer();
}
}

/// A default style sheet generator.
Expand Down
Loading