LinkTextSpans function
TextSpan
LinkTextSpans({
- required String text,
- TextStyle? textStyle,
- TextStyle? linkStyle,
- LinkTapHandler? onLinkTap,
- ThemeData? themeData,
Implementation
TextSpan LinkTextSpans(
{required String text,
TextStyle? textStyle,
TextStyle? linkStyle,
LinkTapHandler? onLinkTap,
ThemeData? themeData}) {
Future<void> launchUrlIfHandler(Uri url) async {
if (onLinkTap != null) {
onLinkTap(url);
return;
}
if (await canLaunchUrl(url)) {
await launchUrl(url);
} else {
throw 'Could not launch $url';
}
}
textStyle ??= themeData?.textTheme.bodyMedium;
linkStyle ??= themeData?.textTheme.bodyMedium?.copyWith(
color: themeData.colorScheme.secondary,
decoration: TextDecoration.underline,
);
// first estimate if we are going to have matches at all
final estimateMatches = _estimateRegex.allMatches(text);
if (estimateMatches.isEmpty) {
return TextSpan(
text: text,
style: textStyle,
children: const [],
);
}
// Our _regex uses lookbehinds for nicer matching, which isn't supported by all browsers yet.
// Sadly, an error is only thrown on usage. So, we try to match against an empty string to get
// our error ASAP and then determine the regex we use based on that.
RegExp regexToUse;
try {
_regex.hasMatch('');
regexToUse = _regex;
} catch (_) {
regexToUse = _fallbackRegex;
}
List<RegExpMatch>? links;
List<String>? textParts;
if (text.length > 300) {
// we have a super long text, let's try to split it up
links = [];
// thing greatly simplify if the textParts.last is already a string
textParts = [''];
// now we will separate the `text` into chunks around their matches, and then apply the regex
// only to those substrings.
// As we already estimated some matches, we know the for-loop will run at least once, simplifying things
// we will need to make sure to merge overlapping chunks together
var curStart = -1; // the current chunk start
var curEnd = 0; // the current chunk end
var lastEnd = 0; // the last chunk end, where we stopped parsing
var abort = false; // should we abort and fall back to the slow method?
void processChunk() {
if (textParts == null || links == null) {
abort = true;
links = null;
textParts = null;
return;
}
// we gotta make sure to save the text fragment between the current and the last chunk
final firstFragment = text.substring(lastEnd, curStart);
if (firstFragment.isNotEmpty) {
textParts!.last += firstFragment;
}
// fetch our current fragment...
final fragment = text.substring(curStart, curEnd);
// add all the links
links!.addAll(regexToUse.allMatches(fragment));
// and fetch the text parts
final fragmentTextParts = fragment.split(regexToUse);
// if the first of last text part is empty, that means that the chunk wasn't big enough to fit the full URI
// thus we abort and fall back to the slow method
if ((fragmentTextParts.first.isEmpty && curStart > 0) ||
(fragmentTextParts.last.isEmpty && curEnd < text.length)) {
abort = true;
links = null;
textParts = null;
return;
}
// add all the text parts correctly
textParts!.last += fragmentTextParts.removeAt(0);
textParts!.addAll(fragmentTextParts);
// and save the lastEnd for later
lastEnd = curEnd;
}
for (final e in estimateMatches) {
const int kChunkSize = 120;
final start = max(e.start - kChunkSize, 0);
final end = min(e.start + kChunkSize, text.length);
if (start < curEnd) {
// merge blocks
curEnd = end;
} else {
// new block! And proccess the last chunk!
if (curStart != -1) {
processChunk();
}
curStart = start;
curEnd = end;
}
if (abort) {
break;
}
}
// we musn't forget to proccess the last chunk
if (!abort) {
processChunk();
}
if (!abort) {
// and we musn't forget to add the last fragment
final lastFragment = text.substring(lastEnd, text.length);
if (lastFragment.isNotEmpty && textParts != null) {
textParts!.last += lastFragment;
}
}
}
links ??= regexToUse.allMatches(text).toList();
if (links!.isEmpty) {
return TextSpan(
text: text,
style: textStyle,
children: const [],
);
}
textParts ??= text.split(regexToUse);
final textSpans = <InlineSpan>[];
int i = 0;
for (var part in textParts!) {
textSpans.add(TextSpan(text: part, style: textStyle));
if (i < links!.length) {
final element = links![i];
final linkText = element.group(0) ?? '';
var link = linkText;
final scheme = element.group(1);
final tldUrl = element.group(2);
final tldEmail = element.group(3);
var valid = true;
if (scheme?.isNotEmpty ?? false) {
// we have to validate the scheme
valid = kAllSchemes.contains(scheme!.toLowerCase());
}
if (valid && (tldUrl?.isNotEmpty ?? false)) {
// we have to validate if the tld exists
valid = kAllTlds.contains(tldUrl!.toLowerCase());
link = 'https://$link';
}
if (valid && (tldEmail?.isNotEmpty ?? false)) {
// we have to validate if the tld exists
valid = kAllTlds.contains(tldEmail!.toLowerCase());
link = 'mailto:$link';
}
final uri = Uri.tryParse(link);
if (valid && uri != null) {
if (kIsWeb) {
// on web recognizer in TextSpan does not work properly, so we use normal text w/ inkwell
textSpans.add(
WidgetSpan(
child: InkWell(
onTap: () => launchUrlIfHandler(uri),
child: Text(linkText, style: linkStyle),
),
),
);
} else {
textSpans.add(
LinkTextSpan(
text: linkText,
style: linkStyle,
url: uri,
onLinkTap: launchUrlIfHandler,
),
);
}
} else {
textSpans.add(TextSpan(text: linkText, style: textStyle));
}
i++;
}
}
return TextSpan(text: '', children: textSpans);
}