diff --git a/.maestro/enrichedInput/flows/links_visual.yaml b/.maestro/enrichedInput/flows/links_visual.yaml
new file mode 100644
index 000000000..bf3ad4c47
--- /dev/null
+++ b/.maestro/enrichedInput/flows/links_visual.yaml
@@ -0,0 +1,73 @@
+appId: swmansion.enriched.example
+---
+# Verifies typing a URL directly into the editor
+- launchApp
+
+- tapOn:
+ id: 'toggle-screen-button'
+
+- tapOn:
+ id: "editor-input"
+
+# autolinks break on text change or paste
+- inputText: 'swmansion.com'
+
+- doubleTapOn:
+ id: 'editor-input'
+ point: '20%, 50%'
+- tapOn:
+ text: 'Copy'
+
+- tapOn:
+ id: 'editor-input'
+ point: '70%, 50%'
+- pressKey: Enter
+
+- longPressOn:
+ id: 'editor-input'
+ point: '50%, 70%'
+- tapOn:
+ text: 'Paste'
+- inputText: 'm'
+
+- pressKey: Enter
+- inputText: 'swm'
+- longPressOn:
+ id: 'editor-input'
+ point: '50%, 75%'
+- tapOn:
+ text: 'Paste'
+
+- runFlow:
+ file: '../subflows/capture_or_assert_screenshot.yaml'
+ env:
+ SCREENSHOT_NAME: 'links_visual_auto'
+
+# manual link where href == textValue but does not match the linkRegex is considered as a manual link
+- runFlow:
+ file: '../subflows/set_editor_value.yaml'
+ env:
+ VALUE: '
example.com
'
+
+- doubleTapOn:
+ id: 'editor-input'
+ point: '20%, 50%'
+- tapOn:
+ text: 'Copy'
+
+- tapOn:
+ id: 'editor-input'
+ point: '70%, 50%'
+- pressKey: Enter
+
+- longPressOn:
+ id: 'editor-input'
+ point: '50%, 70%'
+- tapOn:
+ text: 'Paste'
+- pressKey: Backspace
+
+- runFlow:
+ file: '../subflows/capture_or_assert_screenshot.yaml'
+ env:
+ SCREENSHOT_NAME: 'links_visual_manual'
diff --git a/.maestro/enrichedInput/screenshots/android/links_visual_auto.png b/.maestro/enrichedInput/screenshots/android/links_visual_auto.png
new file mode 100644
index 000000000..369f3a2ea
Binary files /dev/null and b/.maestro/enrichedInput/screenshots/android/links_visual_auto.png differ
diff --git a/.maestro/enrichedInput/screenshots/android/links_visual_manual.png b/.maestro/enrichedInput/screenshots/android/links_visual_manual.png
new file mode 100644
index 000000000..cc265a9db
Binary files /dev/null and b/.maestro/enrichedInput/screenshots/android/links_visual_manual.png differ
diff --git a/.maestro/enrichedInput/screenshots/ios/links_visual_auto.png b/.maestro/enrichedInput/screenshots/ios/links_visual_auto.png
new file mode 100644
index 000000000..2efbdbf07
Binary files /dev/null and b/.maestro/enrichedInput/screenshots/ios/links_visual_auto.png differ
diff --git a/.maestro/enrichedInput/screenshots/ios/links_visual_manual.png b/.maestro/enrichedInput/screenshots/ios/links_visual_manual.png
new file mode 100644
index 000000000..bb0bd58f7
Binary files /dev/null and b/.maestro/enrichedInput/screenshots/ios/links_visual_manual.png differ
diff --git a/android/src/main/java/com/swmansion/enriched/common/parser/EnrichedParser.java b/android/src/main/java/com/swmansion/enriched/common/parser/EnrichedParser.java
index 02d52f706..cb1060726 100644
--- a/android/src/main/java/com/swmansion/enriched/common/parser/EnrichedParser.java
+++ b/android/src/main/java/com/swmansion/enriched/common/parser/EnrichedParser.java
@@ -66,6 +66,11 @@ private static class HtmlParser {
}
public static Spanned fromHtml(String source, T style, EnrichedSpanFactory spanFactory) {
+ return fromHtml(source, style, spanFactory, null);
+ }
+
+ public static Spanned fromHtml(
+ String source, T style, EnrichedSpanFactory spanFactory, Pattern linkRegex) {
Parser parser = new Parser();
try {
parser.setProperty(Parser.schemaProperty, HtmlParser.schema);
@@ -74,7 +79,7 @@ public static Spanned fromHtml(String source, T style, EnrichedSpanFactory implements ContentHandler {
private final String mSource;
private final XMLReader mReader;
private final SpannableStringBuilder mSpannableStringBuilder;
+ private final Pattern mLinkRegex;
private static Integer currentOrderedListItemIndex = 0;
private static Boolean isInOrderedList = false;
private static Boolean isInCheckboxList = false;
@@ -432,12 +438,17 @@ private static void pushAlignmentMark(Editable text, Attributes attributes) {
}
public HtmlToSpannedConverter(
- String source, T style, Parser parser, EnrichedSpanFactory spanFactory) {
+ String source,
+ T style,
+ Parser parser,
+ EnrichedSpanFactory spanFactory,
+ Pattern linkRegex) {
mStyle = style;
mSource = source;
mSpannableStringBuilder = new SpannableStringBuilder();
mReader = parser;
mSpanFactory = spanFactory;
+ mLinkRegex = linkRegex;
}
public Spanned convert() {
@@ -602,7 +613,7 @@ private void handleEndTag(String tag) {
} else if (tag.equalsIgnoreCase("codeblock")) {
endCodeBlock(mSpannableStringBuilder, mStyle, mSpanFactory);
} else if (tag.equalsIgnoreCase("a")) {
- endA(mSpannableStringBuilder, mStyle, mSpanFactory);
+ endA(mSpannableStringBuilder, mStyle, mSpanFactory, mLinkRegex);
} else if (tag.equalsIgnoreCase("u")) {
end(mSpannableStringBuilder, Underline.class, mSpanFactory.createUnderlineSpan(mStyle));
} else if (tag.equalsIgnoreCase("s")) {
@@ -862,12 +873,18 @@ private static void startA(Editable text, Attributes attributes) {
start(text, new Href(href));
}
- private static void endA(Editable text, T style, EnrichedSpanFactory spanFactory) {
+ private static boolean urlMatchesLinkRegex(String url, Pattern linkRegex) {
+ if (linkRegex == null) return false;
+ return linkRegex.matcher(url).matches();
+ }
+
+ private static void endA(
+ Editable text, T style, EnrichedSpanFactory spanFactory, Pattern linkRegex) {
Href h = getLast(text, Href.class);
- if (h != null) {
- if (h.mHref != null) {
- setSpanFromMark(text, h, spanFactory.createLinkSpan(h.mHref, style));
- }
+ if (h != null && h.mHref != null) {
+ String linkText = text.subSequence(text.getSpanStart(h), text.length()).toString();
+ boolean isManual = !linkText.equals(h.mHref) || !urlMatchesLinkRegex(h.mHref, linkRegex);
+ setSpanFromMark(text, h, spanFactory.createLinkSpan(h.mHref, style, isManual));
}
}
diff --git a/android/src/main/java/com/swmansion/enriched/common/parser/EnrichedSpanFactory.kt b/android/src/main/java/com/swmansion/enriched/common/parser/EnrichedSpanFactory.kt
index f61ef8d60..38fb167b1 100644
--- a/android/src/main/java/com/swmansion/enriched/common/parser/EnrichedSpanFactory.kt
+++ b/android/src/main/java/com/swmansion/enriched/common/parser/EnrichedSpanFactory.kt
@@ -37,6 +37,7 @@ interface EnrichedSpanFactory {
fun createLinkSpan(
url: String,
style: T,
+ isManual: Boolean,
): EnrichedLinkSpan
fun createMentionSpan(
diff --git a/android/src/main/java/com/swmansion/enriched/text/EnrichedTextSpanFactory.kt b/android/src/main/java/com/swmansion/enriched/text/EnrichedTextSpanFactory.kt
index c2e1db3da..f61ac812b 100644
--- a/android/src/main/java/com/swmansion/enriched/text/EnrichedTextSpanFactory.kt
+++ b/android/src/main/java/com/swmansion/enriched/text/EnrichedTextSpanFactory.kt
@@ -38,6 +38,7 @@ class EnrichedTextSpanFactory : EnrichedSpanFactory {
override fun createLinkSpan(
url: String,
style: EnrichedTextStyle,
+ isManual: Boolean,
) = EnrichedTextLinkSpan(url, style)
override fun createMentionSpan(
diff --git a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputSpannableFactory.kt b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputSpannableFactory.kt
index d18e16c00..fadeb6e1b 100644
--- a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputSpannableFactory.kt
+++ b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputSpannableFactory.kt
@@ -40,7 +40,8 @@ class EnrichedTextInputSpannableFactory : EnrichedSpanFactory {
override fun createLinkSpan(
url: String,
style: HtmlStyle,
- ) = EnrichedInputLinkSpan(url, style, true)
+ isManual: Boolean,
+ ) = EnrichedInputLinkSpan(url, style, isManual)
override fun createMentionSpan(
text: String,
diff --git a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt
index cdcad4e2f..5be5ee69b 100644
--- a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt
+++ b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt
@@ -9,6 +9,7 @@ import android.graphics.Color
import android.graphics.Rect
import android.graphics.text.LineBreaker
import android.os.Build
+import android.text.Editable
import android.text.InputType
import android.text.Spannable
import android.text.SpannableString
@@ -398,8 +399,9 @@ class EnrichedTextInputView :
val pasteEnd = (start + insertedLength).coerceIn(0, finalText.length)
setSelection(pasteEnd)
- // Detect links in the newly pasted range
- parametrizedStyles?.detectLinksInRange(finalText, start.coerceAtMost(pasteEnd), pasteEnd)
+ // Update links and mentions in the newly pasted range
+ val editable = text as? Editable ?: return
+ parametrizedStyles?.afterTextChanged(editable, start.coerceAtMost(pasteEnd), pasteEnd)
}
fun requestFocusProgrammatically() {
@@ -413,7 +415,7 @@ class EnrichedTextInputView :
val normalized = GumboNormalizer.normalizeHtml(text.toString()) ?: return text
return try {
- val parsed = EnrichedParser.fromHtml(normalized, htmlStyle, spannableFactory)
+ val parsed = EnrichedParser.fromHtml(normalized, htmlStyle, spannableFactory, linkRegex)
parsed.trimEnd('\n')
} catch (e: Exception) {
Log.e(TAG, "Error parsing normalized HTML: ${e.message}")
@@ -426,7 +428,7 @@ class EnrichedTextInputView :
if (isInternalHtml) {
try {
- val parsed = EnrichedParser.fromHtml(text.toString(), htmlStyle, spannableFactory)
+ val parsed = EnrichedParser.fromHtml(text.toString(), htmlStyle, spannableFactory, linkRegex)
return parsed.trimEnd('\n')
} catch (e: Exception) {
Log.e(TAG, "Error parsing HTML: ${e.message}")
diff --git a/ios/EnrichedTextInputView.mm b/ios/EnrichedTextInputView.mm
index 21ff1b721..7fa3f322f 100644
--- a/ios/EnrichedTextInputView.mm
+++ b/ios/EnrichedTextInputView.mm
@@ -719,6 +719,15 @@ - (void)updateProps:(Props::Shared const &)props
textShortcuts = shortcuts;
}
+ // linkRegex
+ LinkRegexConfig *oldRegexConfig =
+ [[LinkRegexConfig alloc] initWithLinkRegexProp:oldViewProps.linkRegex];
+ LinkRegexConfig *newRegexConfig =
+ [[LinkRegexConfig alloc] initWithLinkRegexProp:newViewProps.linkRegex];
+ if (![newRegexConfig isEqualToConfig:oldRegexConfig]) {
+ [config setLinkRegexConfig:newRegexConfig];
+ }
+
// default value - must be set before placeholder to make sure it correctly
// shows on first mount
if (newViewProps.defaultValue != oldViewProps.defaultValue) {
@@ -775,15 +784,6 @@ - (void)updateProps:(Props::Shared const &)props
[config setMentionIndicators:newIndicators];
}
- // linkRegex
- LinkRegexConfig *oldRegexConfig =
- [[LinkRegexConfig alloc] initWithLinkRegexProp:oldViewProps.linkRegex];
- LinkRegexConfig *newRegexConfig =
- [[LinkRegexConfig alloc] initWithLinkRegexProp:newViewProps.linkRegex];
- if (![newRegexConfig isEqualToConfig:oldRegexConfig]) {
- [config setLinkRegexConfig:newRegexConfig];
- }
-
// selection color sets both selection and cursor on iOS (just as in RN)
if (newViewProps.selectionColor != oldViewProps.selectionColor) {
if (isColorMeaningful(newViewProps.selectionColor)) {
diff --git a/ios/htmlParser/HtmlParser.h b/ios/htmlParser/HtmlParser.h
index c079ccec3..ee23cb863 100644
--- a/ios/htmlParser/HtmlParser.h
+++ b/ios/htmlParser/HtmlParser.h
@@ -2,10 +2,13 @@
#import "EnrichedViewHost.h"
#import
+@class EnrichedConfig;
+
@interface HtmlParser : NSObject
+ (NSString *_Nullable)initiallyProcessHtml:(NSString *_Nonnull)html
useHtmlNormalizer:(BOOL)useHtmlNormalizer;
-+ (NSArray *_Nonnull)getTextAndStylesFromHtml:(NSString *_Nonnull)fixedHtml;
++ (NSArray *_Nonnull)getTextAndStylesFromHtml:(NSString *_Nonnull)fixedHtml
+ config:(EnrichedConfig *_Nullable)config;
+ (NSString *_Nonnull)parseToHtmlFromRange:(NSRange)range
host:(id)host;
@end
diff --git a/ios/htmlParser/HtmlParser.mm b/ios/htmlParser/HtmlParser.mm
index a6e84dd6e..543c835cb 100644
--- a/ios/htmlParser/HtmlParser.mm
+++ b/ios/htmlParser/HtmlParser.mm
@@ -1,6 +1,7 @@
#import "HtmlParser.h"
#import "AlignmentEntry.h"
#import "AlignmentUtils.h"
+#import "EnrichedConfig.h"
#import "ImageData.h"
#import "LinkData.h"
#import "MentionParams.h"
@@ -414,7 +415,9 @@ + (NSString *_Nullable)initiallyProcessHtml:(NSString *_Nonnull)html
return fixedHtml;
}
-+ (NSArray *_Nonnull)getTextAndStylesFromHtml:(NSString *_Nonnull)fixedHtml {
++ (NSArray *_Nonnull)getTextAndStylesFromHtml:(NSString *_Nonnull)fixedHtml
+ config:
+ (EnrichedConfig *_Nullable)config {
NSMutableString *plainText = [[NSMutableString alloc] initWithString:@""];
NSMutableDictionary *ongoingTags = [[NSMutableDictionary alloc] init];
NSMutableArray *initiallyProcessedTags = [[NSMutableArray alloc] init];
@@ -747,7 +750,9 @@ + (NSArray *_Nonnull)getTextAndStylesFromHtml:(NSString *_Nonnull)fixedHtml {
LinkData *linkData = [[LinkData alloc] init];
linkData.url = url;
linkData.text = text;
- linkData.isManual = ![text isEqualToString:url];
+ linkData.isManual = !([text isEqualToString:url] &&
+ [LinkStyle matchesLinkRegexWithConfig:url
+ config:config]);
stylePair.styleValue = linkData;
} else if ([tagName isEqualToString:@"mention"]) {
diff --git a/ios/inputHtmlParser/InputHtmlParser.mm b/ios/inputHtmlParser/InputHtmlParser.mm
index bc9e5875b..05c331b75 100644
--- a/ios/inputHtmlParser/InputHtmlParser.mm
+++ b/ios/inputHtmlParser/InputHtmlParser.mm
@@ -24,7 +24,8 @@ - (void)replaceWholeFromHtml:(NSString *_Nonnull)html {
_input->textView.typingAttributes = _input->defaultTypingAttributes;
@try {
- NSArray *processingResult = [HtmlParser getTextAndStylesFromHtml:html];
+ NSArray *processingResult =
+ [HtmlParser getTextAndStylesFromHtml:html config:_input.config];
NSString *plainText = (NSString *)processingResult[0];
NSArray *stylesInfo = (NSArray *)processingResult[1];
NSArray *alignments = (NSArray *)processingResult[2];
@@ -50,7 +51,8 @@ - (void)replaceWholeFromHtml:(NSString *_Nonnull)html {
- (void)replaceFromHtml:(NSString *_Nonnull)html range:(NSRange)range {
@try {
- NSArray *processingResult = [HtmlParser getTextAndStylesFromHtml:html];
+ NSArray *processingResult =
+ [HtmlParser getTextAndStylesFromHtml:html config:_input.config];
NSString *plainText = (NSString *)processingResult[0];
NSArray *stylesInfo = (NSArray *)processingResult[1];
NSArray *alignments = (NSArray *)processingResult[2];
@@ -81,7 +83,8 @@ - (void)replaceFromHtml:(NSString *_Nonnull)html range:(NSRange)range {
- (void)insertFromHtml:(NSString *_Nonnull)html location:(NSInteger)location {
@try {
- NSArray *processingResult = [HtmlParser getTextAndStylesFromHtml:html];
+ NSArray *processingResult =
+ [HtmlParser getTextAndStylesFromHtml:html config:_input.config];
NSString *plainText = (NSString *)processingResult[0];
NSArray *stylesInfo = (NSArray *)processingResult[1];
NSArray *alignments = (NSArray *)processingResult[2];
diff --git a/ios/interfaces/StyleHeaders.h b/ios/interfaces/StyleHeaders.h
index 1a16d3819..aca5d4a3c 100644
--- a/ios/interfaces/StyleHeaders.h
+++ b/ios/interfaces/StyleHeaders.h
@@ -4,6 +4,8 @@
#import "MentionParams.h"
#import "StyleBase.h"
+@class EnrichedConfig;
+
@interface BoldStyle : StyleBase
@end
@@ -28,6 +30,8 @@
- (void)handleAutomaticLinks:(NSString *)word inRange:(NSRange)wordRange;
- (void)handleManualLinks:(NSString *)word inRange:(NSRange)wordRange;
- (void)applyLinkMetaWithData:(LinkData *)linkData range:(NSRange)range;
++ (BOOL)matchesLinkRegexWithConfig:(NSString *)url
+ config:(EnrichedConfig *)config;
@end
@interface MentionStyle : StyleBase
diff --git a/ios/styles/LinkStyle.mm b/ios/styles/LinkStyle.mm
index ef600c6c4..1eae1a1d1 100644
--- a/ios/styles/LinkStyle.mm
+++ b/ios/styles/LinkStyle.mm
@@ -326,28 +326,10 @@ - (void)handleAutomaticLinks:(NSString *)word inRange:(NSRange)wordRange {
}
// all conditions are met; try matching the word to a proper regex
-
- NSString *regexPassedUrl = nullptr;
- NSRange matchingRange = NSMakeRange(0, word.length);
-
- if (linkRegexConfig.isDefault) {
- // use default regex
- regexPassedUrl = [self tryMatchingDefaultLinkRegex:word
- matchRange:matchingRange];
- } else {
- // use user defined regex if it exists
- NSRegularExpression *userRegex = [self.host.config parsedLinkRegex];
-
- if (userRegex == nullptr) {
- // fallback to default regex
- regexPassedUrl = [self tryMatchingDefaultLinkRegex:word
- matchRange:matchingRange];
- } else if ([userRegex numberOfMatchesInString:word
- options:0
- range:matchingRange]) {
- regexPassedUrl = word;
- }
- }
+ NSString *regexPassedUrl =
+ [LinkStyle matchesLinkRegexWithConfig:word config:self.host.config]
+ ? word
+ : nullptr;
if (regexPassedUrl != nullptr) {
// add style only if needed
@@ -378,21 +360,25 @@ - (void)handleAutomaticLinks:(NSString *)word inRange:(NSRange)wordRange {
}
}
-- (NSString *)tryMatchingDefaultLinkRegex:(NSString *)word
- matchRange:(NSRange)range {
- if ([[LinkStyle fullRegex] numberOfMatchesInString:word
++ (BOOL)matchesLinkRegexWithConfig:(NSString *)url
+ config:(EnrichedConfig *)config {
+ LinkRegexConfig *linkRegexConfig = [config linkRegexConfig];
+ if (linkRegexConfig == nullptr || linkRegexConfig.isDisabled) {
+ return NO;
+ }
+ NSRange range = NSMakeRange(0, url.length);
+ NSRegularExpression *userRegex = [config parsedLinkRegex];
+ if (linkRegexConfig.isDefault || userRegex == nullptr) {
+ return [[self fullRegex] numberOfMatchesInString:url
options:0
- range:range] ||
- [[LinkStyle wwwRegex] numberOfMatchesInString:word
- options:0
- range:range] ||
- [[LinkStyle bareRegex] numberOfMatchesInString:word
+ range:range] > 0 ||
+ [[self wwwRegex] numberOfMatchesInString:url options:0
+ range:range] > 0 ||
+ [[self bareRegex] numberOfMatchesInString:url
options:0
- range:range]) {
- return word;
+ range:range] > 0;
}
-
- return nullptr;
+ return [userRegex numberOfMatchesInString:url options:0 range:range] > 0;
}
// handles refreshing manual links
diff --git a/ios/textHtmlParser/TextHtmlParser.mm b/ios/textHtmlParser/TextHtmlParser.mm
index 1f930a015..3899fd2d8 100644
--- a/ios/textHtmlParser/TextHtmlParser.mm
+++ b/ios/textHtmlParser/TextHtmlParser.mm
@@ -31,7 +31,8 @@ - (void)replaceWholeFromHtml:(NSString *_Nonnull)html {
return;
}
- NSArray *result = [HtmlParser getTextAndStylesFromHtml:normalized];
+ NSArray *result = [HtmlParser getTextAndStylesFromHtml:normalized
+ config:nil];
NSString *plainText = result[0];
NSArray *processedStyles = result[1];
NSArray *alignments = result[2];